diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf82ff..3e88bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Support the CLI 0.17.1 automation stop-condition flow: the automation host + answers the new `automation/pause` reverse RPC, scheduled runs keep the + `CronPauseSelf` tool available (pausing their own schedule parks it as + `paused` until a schedule change resumes it) while still denying schedule + management, and the tool joins the Cron denylist when the automations + setting is off. On CLI 0.17.1 the tool was previously dead in both modes. +- Answer the CLI 0.17.1 `workspace/mcp/trust` reverse request with the + contract's schema-valid "trust store unavailable" result. Verified against + the installed 0.17.1 bundle that the child never sends it and that the MCP + servers the bridge injects are exempt from the native workspace trust gate. - Classify the observed transient Z.ai account failures as typed internal provider errors so bb's Provider retry plugin can retry the failed turn up to four times with backoff. Retry remains owned by bb; other authentication and diff --git a/docs/capability-audit.md b/docs/capability-audit.md index 7db01b2..45c6fe7 100644 --- a/docs/capability-audit.md +++ b/docs/capability-audit.md @@ -14,6 +14,26 @@ every native tool works against a production model. A coverage percentage would be misleading: native CLI behavior, host services, and desktop UI features have different integration requirements. +## Re-verification: CLI 0.17.1 drift (2026-11-02) + +The installed app updated to Desktop 3.17.1 / CLI 0.17.1 (upstream sync +`a60ba6a`, 2026-10-30). The wire schemas for `session/create`/`resume`, +runtime preferences and the v4 commands are unchanged, `npm run test:live` +passed against the installed 0.17.1 (`RUNTIME_VERIFIED`, exit 0), and seven +protocol methods were added upstream; nothing the adapter uses was removed. + +| 0.17.1 addition | Status | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `automation/pause` + `CronPauseSelf` tool | **Implemented.** The automation host answers the sixth `automation/*` reverse RPC (`{automationId}` → `{paused}`); scheduled runs keep `CronPauseSelf` available (their only sanctioned cron tool) while still denying schedule management, and it joins the Cron denylist when the automations setting is off. On 0.17.1 the tool was previously dead in both modes | +| `session/lifecycle/{changed,getState,ensureReady,stop,restart}` | Not used. Different failure axis from the shipped crash recovery: PR #6 restarts the bb worker after child-process death, while lifecycle/restart recovers a faulted session inside a live child. Kept as a capability-gated follow-up; 0.16.9 (the oldest supported CLI) lacks the methods | +| `workspace/mcp/trust` | **Verified and closed.** The 0.17.1 child never sends this reverse request: trust records live in the CLI's own user-domain store and rich clients record decisions through the CLI server, so the headless child has no prompt path. Servers the bridge injects at `session/create` are exempt from the workspace trust gate; servers declared in workspace config files are gated by the child itself (status `untrusted`, tools not loaded). The bridge answers the contract's schema-valid `{accepted: false, reasonCode: "workspace_mcp_trust_unavailable"}`, so a newer CLI that starts persisting decisions through the host degrades gracefully instead of hitting a capability error | +| `usage/stats`, `session/usage` deprecated upstream | Informational: hosts moved to `v4/conversation/usage`. `usage/stats` is unused; `session/usage` is still requested on the adapter's optional best-effort resume path (usage restore, `src/bridge.ts`) and remains served in 0.17.1, so the deprecation changes nothing yet | + +New internal CLI modules in 0.17.1 (provider-builtin-tool-compat for GLM +server-side tools, mcp-node-fallback, off-peak retry, dynamic-workflow quota +resume, the ZCode Sites command endpoint) need no bridge work. The baseline +matrix below and the 2026-10-20 gap table remain current for everything else. + ## Re-verification: unchanged upstream surface and remaining gaps (2026-10-20) The upstream protocol surface was re-diffed against the v0.5.0 refresh pin diff --git a/docs/configuration.md b/docs/configuration.md index 7d0baae..4bc4b37 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,8 @@ as an argv array; there is no shell interpolation. Enable `automationsEnabled` to let ZCode sessions create scheduled tasks through the native Cron tools (`CronCreate`, `CronList`, `CronUpdate`, -`CronDelete`). The plugin answers the native `automation/*` host ports from +`CronDelete`, plus `CronPauseSelf` on CLI 0.17.1+). The plugin answers the +native `automation/*` host ports from a local store (`automations.json` in the plugin's bridge-data directory) and runs schedules itself: @@ -47,7 +48,9 @@ runs schedules itself: cold-resumes the saved native session, applies the saved model and mode, and sends the prompt with the native automation marker, so ZCode blocks recursive task creation for that turn. The reply streams into the same bb - thread. + thread. Runs cannot manage schedules, but may pause their own schedule + with `CronPauseSelf` when the prompt's stop condition is met; a paused + schedule only resumes through a schedule change (`CronUpdate`). - The scheduler lives in the bridge worker and only runs while bb is open - like ZCode Desktop, schedules do not fire while the app is closed. Runs missed while closed are skipped; recurring schedules continue from the diff --git a/knowledge/contracts/protocol-contracts.md b/knowledge/contracts/protocol-contracts.md index 6257f5f..d12aff1 100644 --- a/knowledge/contracts/protocol-contracts.md +++ b/knowledge/contracts/protocol-contracts.md @@ -34,6 +34,8 @@ verified: at: 2026-10-20T11:23:47Z - by: Codex at: 2026-10-22T11:22:00Z + - by: zcode-agent/GLM-5.3 + at: 2026-11-02T09:50:00Z --- # Protocol contracts @@ -47,7 +49,7 @@ the sources when `stale_after` passes or an upstream release lands. | Boundary | Contract | Verified baseline | Upstream source | | ------------------- | ------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | bb ↔ plugin | Provider bridge protocol 2, delta grammar 3 (SDK) | SDK `@get-bb/plugin-sdk` 0.6.49 | [bb @ 9937c46](https://github.com/get-bb/bb/tree/9937c46f5c7e369de3563eb864c8cbc4c3b9c231) | -| plugin ↔ ZCode CLI | "ZCode Protocol" version 1 over stdio JSON lines | CLI 0.17.0 (app 3.16.2), macOS; 0.16.9 in the v0.5.0 record | [ZCode @ 0a1fc56](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411) | +| plugin ↔ ZCode CLI | "ZCode Protocol" version 1 over stdio JSON lines | CLI 0.17.1 (app 3.17.1), macOS; 0.16.9 in the v0.5.0 record | [ZCode @ 0a1fc56](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411) | | marketplace catalog | bb marketplace schema v2 (generated) | `marketplace.json` at repo HEAD | [docs/marketplace.md](../../docs/marketplace.md) | Engine ranges (`package.json`): Node `>=22` (24+ recommended for use), bb @@ -93,6 +95,31 @@ snapshots. protocol method and v4 command surfaces are unchanged versus the 0a1fc56 pin (110 methods, none added or removed), so the [capability audit](../../docs/capability-audit.md) inventory is current. +- **Re-verification 2026-11-02**: the installed app updated to 3.17.1 with CLI + bundle **0.17.1** (upstream sync `a60ba6a`, 2026-10-30; read from + `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs`). `npm run +test:live` reported `RUNTIME_VERIFIED`, exit 0; the create/resume, + runtime-preferences and v4 command schemas are unchanged. Seven methods + were added upstream: `automation/pause` — now served by + `src/automations.ts`; it backs the `CronPauseSelf` stop-condition tool a + scheduled run calls on itself (params `{automationId, workspace?}` → result + `{paused}`; `paused: false` means already paused/completed/failed/unknown), + and the dispatch denylist splits into management tools versus the self-pause + exit — `CronPauseSelf` joins `CRON_TOOL_NAMES` (hidden when the setting is + off) but not the per-run denylist. `session/lifecycle/{changed, +getState,ensureReady,stop,restart}` — unused for now; they recover a faulted + session inside a live child (a different axis than the worker-restart crash + recovery) and 0.16.9 lacks them, so adoption waits on a CLI-floor decision. + `workspace/mcp/trust` — verified the 0.17.1 child never sends it to a stdio + host (no sender in the bundle; trust records live in the CLI's user-domain + store and the session-create-injected `mcpServers` are exempt from the + workspace trust gate), so the bridge answers the contract's schema-valid + `{accepted: false, reasonCode: "workspace_mcp_trust_unavailable"}`. Also: + `usage/stats` and `session/usage` are deprecated upstream (hosts moved to + `v4/conversation/usage`); `usage/stats` is unused, while `session/usage` + is still requested on the optional best-effort resume path (usage + restore in `src/bridge.ts`, failure-tolerant) and remains served in + 0.17.1. ## Translation rules diff --git a/knowledge/decisions/feature-roadmap.md b/knowledge/decisions/feature-roadmap.md index 5e6fa06..adea1ee 100644 --- a/knowledge/decisions/feature-roadmap.md +++ b/knowledge/decisions/feature-roadmap.md @@ -103,14 +103,19 @@ Conclusion (2026-10-20): bb's provider bridge has **no host-service channel** ## Stage 6 — automations (v0.10.0) and Off-Peak (v0.11.0), both shipped plugin-side Native automations shipped plugin-side (2026-10-23): `src/automations.ts` -answers the five `automation/*` reverse RPCs from a JSON store in +answers the `automation/*` reverse RPCs from a JSON store in `BB_BRIDGE_DIR` with upstream-matching snapshots, and a bridge-worker scheduler dispatches due tasks by cold-resuming the bound session with the saved provider options, model and mode plus the native `automationId` marker. The `automationsEnabled` setting (off by default) gates both the ports and the Cron tools (denylisted when off). Limits: runs fire only while bb is open; busy sessions defer ten minutes; three consecutive -failures park a schedule as `failed`. The upstream reference +failures park a schedule as `failed`. On 2026-11-02 the sixth method, +`automation/pause` (CLI 0.17.1), made the `CronPauseSelf` stop-condition +tool live: a run pausing its own schedule parks it as `paused` (only a +schedule-semantics update resumes it), the per-run denylist denies +schedule management but keeps the self-pause exit, and `CronPauseSelf` +joins the setting-off denylist. The upstream reference implementations in `packages/services` (`AutomationService`/`OffPeakTaskService`, `zcodeAgentService` handlers) informed the shapes; see the [host-boundary amendment](zcode-desktop-host-boundary.md). Off-Peak shipped diff --git a/knowledge/log.md b/knowledge/log.md index 9b4f9a0..a379f6b 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -311,3 +311,12 @@ green) after removing the Experimental label (v0.14.0/v0.14.1); procedure + the expanded-row graph rendering constraint recorded in runbooks/marketplace-submission.md. +- 2026-11-02: closed the two CLI 0.17.1 gaps from the drift analysis — + `automation/pause` host support makes the `CronPauseSelf` stop-condition + tool live (dispatch denylist keeps the self-pause exit while still denying + schedule management; the tool joins the setting-off denylist), and + `workspace/mcp/trust` was verified never-sent by the 0.17.1 child + (bridge-injected MCP servers are exempt from the native workspace trust + gate) so the bridge answers the contract's unavailable result; the audit + gained the 2026-11-02 re-verification section and + contracts/protocol-contracts.md the 0.17.1 drift record. diff --git a/src/automations.ts b/src/automations.ts index 2f859a7..5fc68b6 100644 --- a/src/automations.ts +++ b/src/automations.ts @@ -8,7 +8,8 @@ import { ProtocolError } from "./client.js"; import { modelRefSchema } from "./protocol.js"; // Native automation contract mirrored from @zcode/shared on upstream main -// (pinned 2026-10-23). The CLI validates every result with its own strict +// (pinned 2026-10-23; automation/pause verified against the installed CLI +// 0.17.1 on 2026-11-02). The CLI validates every result with its own strict // schemas, so field names, enums and the exact field set must match. export const AUTOMATION_METHODS = new Set([ "automation/create", @@ -16,6 +17,7 @@ export const AUTOMATION_METHODS = new Set([ "automation/list", "automation/delete", "automation/checkTaskBinding", + "automation/pause", ]); export const CRON_TOOL_NAMES = [ @@ -23,8 +25,15 @@ export const CRON_TOOL_NAMES = [ "CronList", "CronUpdate", "CronDelete", + "CronPauseSelf", ] as const; +// Scheduled runs must not manage schedules, but CronPauseSelf is the one +// sanctioned cron tool inside a run: it can only pause the automation that +// triggered it, so the dispatch denylist keeps it available (CLI 0.17.1+). +export const CRON_MANAGEMENT_TOOL_NAMES: readonly string[] = + CRON_TOOL_NAMES.filter((name) => name !== "CronPauseSelf"); + // Upstream keeps at most 20 definitions per index; the CLI recognizes the // limit through this token in the message and stops its retry loops. const CREATE_LIMIT = 20; @@ -529,6 +538,40 @@ export function createAutomationService(options?: { return { automation: snapshot(next) }; } + // automation/pause is the host side of the CronPauseSelf tool a scheduled + // run calls on its own schedule when its stop condition is met. Result is + // the strict {paused} shape; paused=false means "not active, nothing + // changed" for unknown, finished or already-paused schedules. + async function pause( + raw: unknown, + cwd: string, + ): Promise<{ paused: boolean }> { + const input = z + .object({ + automationId: z.string().min(1), + // The native params carry the workspace object; scoping stays with + // the session cwd like every other automation method. + workspace: z.unknown().optional(), + }) + .strict() + .parse(raw); + await load(); + const current = loaded!.find( + (a) => a.automationId === input.automationId && a.workspacePath === cwd, + ); + if (!current || current.lifecycleStatus !== "active") + return { paused: false }; + // Only the lifecycle flips: due() skips paused entries, the nextRunAt is + // left for display, and a schedule-semantics update reactivates it. + loaded = loaded!.map((a) => + a.automationId === current.automationId + ? { ...a, lifecycleStatus: "paused" as const } + : a, + ); + await persist(loaded); + return { paused: true }; + } + async function remove( raw: unknown, cwd: string, @@ -694,6 +737,14 @@ export function createAutomationService(options?: { ); return remove(raw, context.cwd); } + if (method === "automation/pause") { + if (!context.cwd) + throw new ProtocolError( + "No active bb session for this request.", + -32601, + ); + return pause(raw, context.cwd); + } if (method === "automation/checkTaskBinding") { if (!context.cwd) throw new ProtocolError( diff --git a/src/bridge.ts b/src/bridge.ts index 2395559..727b82d 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -65,6 +65,7 @@ import { import { History, BackgroundWork, managementCommands } from "./lifecycle.js"; import { AUTOMATION_METHODS, + CRON_MANAGEMENT_TOOL_NAMES, CRON_TOOL_NAMES, createAutomationService, dispatchPermissionMode, @@ -961,6 +962,19 @@ export function createBridge( providerOptions: session?.providerOptions, }); } + // CLI 0.17.1 added a workspace MCP trust control plane: the native side + // keeps its own per-workspace trust store and gates servers declared in + // workspace config files behind it; the mcpServers this bridge injects at + // session/create are exempt. Verified against the installed 0.17.1 bundle + // (2026-11-02): the child never sends this reverse request - trust + // decisions are recorded client-side - but the contract defines an + // explicit "trust store unavailable" answer, which stays schema-valid and + // retry-free if a newer CLI starts persisting decisions through the host. + if (method === "workspace/mcp/trust") + return { + accepted: false, + reasonCode: "workspace_mcp_trust_unavailable", + }; // These native ports need a host service bb's provider bridge does not // expose (verified against SDK 0.6.49: BRIDGE_REQUEST_METHODS has no // host-service channel). Reject uniformly with an actionable reason @@ -1327,6 +1341,8 @@ export function createBridge( if (!session) { // Cold-resume the bound native session with the settings snapshot // captured at creation; scheduled turns never manage schedules. + // CronPauseSelf stays available: pausing its own schedule when the + // run's stop condition is met is the sanctioned exit (CLI 0.17.1+). session = await construct( { threadId: entry.threadId, @@ -1334,7 +1350,7 @@ export function createBridge( providerThreadId: entry.targetTaskId, instructionMode: "replace", options: automationOptions(entry), - disallowedTools: [...CRON_TOOL_NAMES], + disallowedTools: [...CRON_MANAGEMENT_TOOL_NAMES], }, true, ); diff --git a/tests/automations.test.ts b/tests/automations.test.ts index a14487d..970cf27 100644 --- a/tests/automations.test.ts +++ b/tests/automations.test.ts @@ -248,6 +248,68 @@ it("updates semantics without resurrecting finished one-shots cosmetically", asy expect(revived.automation.cronExpr).toBe("*/10 * * * *"); }); +it("pauses an active schedule for CronPauseSelf and keeps it paused", async () => { + const fixed = new Date("2026-10-23T10:00:00Z"); + const s = service(dir, () => fixed); + const created = record( + await s.handle( + "automation/create", + { cronExpr: "0 9 * * *", prompt: "watch the build", title: "Watch" }, + context, + ), + ) as { automation: { automationId: string } }; + const id = created.automation.automationId; + // The native params carry the workspace object next to the id. + const paused = record( + await s.handle( + "automation/pause", + { + automationId: id, + workspace: { workspacePath: "/workspace", workspaceKey: "/workspace" }, + }, + context, + ), + ); + expect(paused).toEqual({ paused: true }); + expect(s.due(new Date("2026-10-24T09:00:00Z"))).toHaveLength(0); + // Strict params: the CLI validates this shape, so unknown fields fail here. + await expect( + s.handle("automation/pause", { automationId: id, extra: 1 }, context), + ).rejects.toThrow(); + const otherWorkspace = { ...context, cwd: "/workspace-b" }; + // Non-active, unknown and cross-workspace schedules answer "nothing + // changed" - the native tool prints its already-paused message for those. + expect( + await s.handle("automation/pause", { automationId: id }, context), + ).toEqual({ paused: false }); + expect( + await s.handle("automation/pause", { automationId: "missing" }, context), + ).toEqual({ paused: false }); + expect( + await s.handle("automation/pause", { automationId: id }, otherWorkspace), + ).toEqual({ paused: false }); + // A cosmetic update must not resurrect the paused schedule... + await s.handle( + "automation/update", + { automationId: id, title: "v2" }, + context, + ); + const afterTitle = record(await s.handle("automation/list", {}, context)) as { + automations: Array>; + }; + expect(afterTitle.automations[0].lifecycleStatus).toBe("paused"); + // ...while a schedule-semantics change resumes it. + const resumed = record( + await s.handle( + "automation/update", + { automationId: id, cronExpr: "0 10 * * *" }, + context, + ), + ) as { automation: Record }; + expect(resumed.automation.lifecycleStatus).toBe("active"); + expect(Number(resumed.automation.nextRunAt)).toBeGreaterThan(fixed.getTime()); +}); + it("completes recurring runs and stops them after three failures", async () => { const fixed = new Date("2026-10-23T10:00:00Z"); const s = service(dir, () => fixed); @@ -513,6 +575,92 @@ it("dispatches a due automation into its bound thread and releases the headless await bridge.close(); }); +it("keeps CronPauseSelf available in dispatched runs and records a self-pause", async () => { + mkdirSync(dir, { recursive: true }); + process.env.BB_BRIDGE_DIR = dir; + // holdTurns keeps the dispatched turn open so the reverse pause lands + // mid-run, exactly where the CLI sends it for the CronPauseSelf tool. + const harness = dispatchHarness(dir, true); + const { bridge, calls, native, cwd, until, rpc } = harness; + writeFileSync( + join(dir, "automations.json"), + JSON.stringify({ + version: 1, + automations: [ + { + automationId: "automation-1", + title: "Watch", + cronExpr: "* * * * *", + prompt: "Run the report", + targetTaskId: "native-1", + threadId: "thread", + workspacePath: cwd, + providerOptions: dispatchOptions.providerOptions, + enabled: true, + lifecycleStatus: "active", + nextRunAt: Date.now() + 1_500, + runCount: 0, + recurring: true, + createdAt: Date.now(), + }, + ], + }), + ); + await rpc("thread/start", { + threadId: "thread", + cwd, + options: dispatchOptions, + instructionMode: "replace", + }); + await rpc("thread/stop", { + threadId: "thread", + providerThreadId: "native-1", + intent: "release", + activeTurnId: null, + }); + await until(() => + calls.some( + (c) => + c.method === "session/send" && c.params.automationId === "automation-1", + ), + ); + // The cold-resume denies schedule management but keeps the self-pause exit. + const resume = calls.find((c) => c.method === "session/resume")!; + const denylist = (resume.params.toolDenylist ?? []) as string[]; + expect(denylist).toContain("CronCreate"); + expect(denylist).toContain("CronUpdate"); + expect(denylist).not.toContain("CronPauseSelf"); + // The CLI answers the CronPauseSelf tool through this reverse RPC. + await expect( + native.get("native-1")!.cb!.request("automation/pause", { + automationId: "automation-1", + }), + ).resolves.toEqual({ paused: true }); + native.get("native-1")!.cb!.notification({ + method: "session/event", + params: { + sessionId: "native-1", + type: "turn.completed", + eventSeq: 2, + turnId: "turn-1", + payload: { resultType: "success", response: "report done" }, + }, + }); + const readStore = () => + JSON.parse(readFileSync(join(dir, "automations.json"), "utf8")) as { + automations: Array>; + }; + // The run still records itself without un-pausing the schedule. + await until( + () => + readStore().automations[0].runCount === 1 && + readStore().automations[0].lifecycleStatus === "paused", + ); + expect(readStore().automations[0].nextRunAt).toBeUndefined(); + await until(() => native.get("native-1")!.closed === true); + await bridge.close(); +}); + it("answers automation reverse RPCs only when the setting is enabled", async () => { const storeDir = mkdtempSync(join(tmpdir(), "bb-zcode-automation-gate-")); const harness = dispatchHarness(storeDir); @@ -530,6 +678,12 @@ it("answers automation reverse RPCs only when the setting is enabled", async () record(calls.find((c) => c.method === "session/create")?.params) .sessionId ?? "", ); + // Without the setting every cron tool - CronPauseSelf included - stays + // hidden instead of erroring at call time. + const create = calls.find((c) => c.method === "session/create")!; + expect((create.params.toolDenylist ?? []) as string[]).toContain( + "CronPauseSelf", + ); // Without the setting the management command reports the ports as disabled, // and the reverse RPC answers with the same actionable error. const response = await rpc("turn/start", { diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index e1e0b73..0df91fd 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -965,3 +965,34 @@ it("rejects native host-service ports with actionable messages", async () => { cb.request("bot/channel/reply", { sessionId: id }), ).rejects.toThrow(/topic resources.*ZCode Desktop/); }); + +it("declines workspace MCP trust with the contract's unavailable answer", async () => { + const h = harness(), + id = await h.start(); + const cb = h.sessions.get(id)!.cb!; + // The 0.17.1 child never sends this reverse request, but the contract + // defines an explicit unavailable result; answering it keeps a newer CLI + // schema-valid instead of tripping over a generic capability error. + const trust = { + workspace: { workspacePath: "/repo", workspaceKey: "/repo" }, + serverName: "project-server", + declarationDigest: "a".repeat(64), + trusted: true, + }; + await expect(cb.request("workspace/mcp/trust", trust)).resolves.toEqual({ + accepted: false, + reasonCode: "workspace_mcp_trust_unavailable", + }); + // The decline is session-independent: the params carry the workspace, so it + // still answers after the session has been released. + await h.rpc("thread/stop", { + threadId: "thread", + providerThreadId: id, + intent: "release", + activeTurnId: null, + }); + await expect(cb.request("workspace/mcp/trust", trust)).resolves.toEqual({ + accepted: false, + reasonCode: "workspace_mcp_trust_unavailable", + }); +});