From 21722322c6e982d720a7041305e9b9cd3b5ac673 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:23:23 -0700 Subject: [PATCH 1/6] feat(driver): Bob 2.0.2 awareness - trust preflight + approval-wedge fast-abort Bob 2.0.2 runs an untrusted workspace on pristine defaults (auto-approve off, command security on, workspace custom modes hidden), ignoring ~/.bob/settings/settings.json - so a headless dispatch there wedges on its first tool prompt or throws "Mode not found". Dispatch now preflights vscode.workspace.isTrusted (new optional host seam; absent = unknown = proceed, so older extension builds keep the pre-2.0.2 behavior) and fails fast naming the folder to trust, before the settings.json auto-approve write. 2.0.2 also persists un-auto-approved tool requests to bob.db (task_pending_approvals) while the task sits frozen on them. The completion watch probes that table: a pending approval older than approvalWedgeMs (default 5s) aborts the dispatch immediately with the tool named, instead of burning the dispatch timeout. Real settle is checked first so a finished turn reports its true outcome past a stale approval row; a pre-2.0.2 store (no table) is a no-op. Every other 2.0.x contract the driver relies on (startTask, tasks/messages schema, lifecycle, mode resolution, settings keys) verified unchanged against the 2.0.2 bundle. --- extension/src/extension.ts | 4 +++ src/bob2-driver.test.ts | 70 ++++++++++++++++++++++++++++++++++++- src/bob2-driver.ts | 47 +++++++++++++++++++++++++ src/bob2-host.test.ts | 6 ++++ src/bob2-host.ts | 6 ++++ src/bob2-taskstore.test.ts | 36 +++++++++++++++++++ src/bob2-taskstore.ts | 71 ++++++++++++++++++++++++++++++++------ 7 files changed, 229 insertions(+), 11 deletions(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index fc42687..301dc63 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -155,6 +155,8 @@ async function detectAndStart(connector: string, force: boolean): Promise const host = mods.createBob2Host({ getExtension: (id: string) => vscode.extensions.getExtension(id), workspaceFolders: () => vscode.workspace.workspaceFolders, + // Live, not captured: trust can be granted mid-session and dispatches must see it (2.0.2 trust gate). + isTrusted: () => vscode.workspace.isTrusted, }); if (mods.isBob2Window(host)) { out.appendLine("[start] Bob 2.0 detected — running the board loop in-process (no IPC child)."); @@ -348,6 +350,8 @@ interface ConnectorModules { createBob2Host: (deps: { getExtension: (id: string) => unknown; workspaceFolders: () => readonly { uri: { fsPath: string } }[] | undefined; + /** Optional in the connector (older builds ignore it): vscode.workspace.isTrusted for the 2.0.2 trust gate. */ + isTrusted?: () => boolean; }) => unknown; isBob2Window: (host: unknown) => boolean; InProcessDriver: new (host: unknown, opts?: unknown) => unknown; diff --git a/src/bob2-driver.test.ts b/src/bob2-driver.test.ts index 22d0f21..be84170 100644 --- a/src/bob2-driver.test.ts +++ b/src/bob2-driver.test.ts @@ -27,6 +27,10 @@ function makeStore() { "CREATE TABLE tasks (id TEXT PRIMARY KEY, parent_id TEXT, status TEXT, directory TEXT, created_at INTEGER, updated_at INTEGER, costs TEXT, last_error TEXT, first_message TEXT, env TEXT)", ); db.exec("CREATE TABLE messages (id TEXT PRIMARY KEY, task_id TEXT, role TEXT, data TEXT, created_at INTEGER)"); + // 2.0.2's persisted-approval table (the taskstore tests cover the pre-2.0.2 store without it). + db.exec( + "CREATE TABLE task_pending_approvals (task_id TEXT NOT NULL, request_id TEXT NOT NULL, payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (task_id, request_id))", + ); const store = new Bob2TaskStore(db); store.close = () => {}; // keep the shared db alive across the driver's per-dispatch open/close const base = Date.now() - 100_000; // created_at values in the recent past, so updated_at can advance past them @@ -74,7 +78,12 @@ function makeStore() { Date.now(), ); }; - return { db, store, seedRoot, seedForeign, bump, setLastError, seedSubtask, finishWith }; + // Bob persisting a tool request the auto-approve config didn't cover (the task is now frozen on it). + const seedApproval = (taskId: string, payload: unknown, createdAt = Date.now()): void => + void db + .prepare("INSERT INTO task_pending_approvals (task_id, request_id, payload_json, created_at) VALUES (?, ?, ?, ?)") + .run(taskId, `req-${++n}`, JSON.stringify(payload), createdAt); + return { db, store, seedRoot, seedForeign, bump, setLastError, seedSubtask, finishWith, seedApproval }; } function makeHost(opts: { @@ -411,6 +420,65 @@ test("externalActivity is false on cold start (bob.db not created yet) — never assert.equal(await driver.externalActivity(60_000), false); }); +// ── 2.0.2: trust preflight + approval-wedge fast-abort ─────────────────────────────────────────── + +test("dispatch fails fast on an untrusted workspace — no settings write, no startTask", async () => { + let writes = 0; + let started = 0; + const host = { ...makeHost({ startTask: () => void started++ }), workspaceTrusted: () => false }; + const res = await new InProcessDriver(host, { writeApproval: () => writes++ }).dispatch({ text: "hi" }); + assert.equal(res.status, "aborted"); + assert.match(res.lastText, /not trusted/); + assert.match(res.lastText, new RegExp(DIR)); // names the folder to trust + assert.equal(writes, 0); // preflight runs BEFORE connect — no settings.json side effect + assert.equal(started, 0); +}); + +test("dispatch proceeds when trust is explicit true or unknown (host without the seam)", async () => { + const { store, seedRoot, bump } = makeStore(); + let id = ""; + const host = { ...makeHost({ startTask: () => void (id = seedRoot("running")) }), workspaceTrusted: () => true }; + const driver = new InProcessDriver(host, { openStore: () => store, ...fast }); + setTimeout(() => bump(id, "active"), 15); + assert.equal((await driver.dispatch({ text: "do it" })).status, "completed"); + // hosts with no workspaceTrusted at all (every other test in this file) are the unknown-trust path +}); + +test("dispatch aborts fast on a wedged approval prompt, naming the tool, instead of burning the timeout", async () => { + const { store, seedRoot, seedApproval } = makeStore(); + let id = ""; + const driver = new InProcessDriver(makeHost({ startTask: () => void (id = seedRoot("running")) }), { + openStore: () => store, + ...fast, + approvalWedgeMs: 10, + }); + // The task stays 'running' (frozen on the prompt); the persisted approval is well past the wedge margin. + setTimeout( + () => seedApproval(id, { signature: { name: "execute_command" }, permission: "execute" }, Date.now() - 60_000), + 12, + ); + const res = await driver.dispatch({ text: "do it", timeoutMs: 5_000 }); + assert.equal(res.status, "aborted"); // aborted long before the 5s timeout — the test would hang otherwise + assert.match(res.lastText, /approval/); + assert.match(res.lastText, /execute_command \(execute\)/); + assert.equal(res.taskId, id); +}); + +test("a fresh approval inside the wedge margin does not abort a turn that then completes", async () => { + const { store, seedRoot, bump, seedApproval } = makeStore(); + let id = ""; + const driver = new InProcessDriver(makeHost({ startTask: () => void (id = seedRoot("running")) }), { + openStore: () => store, + ...fast, + approvalWedgeMs: 60_000, + }); + setTimeout(() => { + seedApproval(id, { signature: { name: "read_file" }, permission: "read" }); // just raised — inside the margin + bump(id, "active"); // ...and Bob resolves it and finishes the turn + }, 12); + assert.equal((await driver.dispatch({ text: "do it" })).status, "completed"); +}); + // ── never-throw contract + busy guard ───────────────────────────────────────────────────────────── test("dispatch returns aborted (never throws) when no workspace folder is open, with no settings write", async () => { diff --git a/src/bob2-driver.ts b/src/bob2-driver.ts index 09fcd0c..7151058 100644 --- a/src/bob2-driver.ts +++ b/src/bob2-driver.ts @@ -4,9 +4,11 @@ import { Bob2TaskStore, awaitTurnSettled, bob2DbExists, + describePendingApproval, parseCosts, sleep, taskError, + turnSettled, DEFAULT_QUIET_MS, DEFAULT_POLL_MS, type Bob2TaskRow, @@ -57,6 +59,11 @@ export interface Bob2Host { /** Bob's open folder as the genuine `vscode.WorkspaceFolder` to pass to startTask (Bob reads `.uri.fsPath` * off it). Opaque so the driver carries no `vscode` type; null when none open. */ workspaceFolderObject(): unknown; + /** `vscode.workspace.isTrusted`, or null when unknown (older extension build). Optional so existing host + * implementations keep compiling; the dispatch preflight hard-fails only on an explicit `false` — + * Bob 2.0.2 runs an untrusted folder on pristine defaults (auto-approve OFF, workspace modes hidden), + * so a headless dispatch there would wedge on its first tool. */ + workspaceTrusted?(): boolean | null; } export interface InProcessDriverOptions { @@ -71,6 +78,9 @@ export interface InProcessDriverOptions { quietMs?: number; /** How long to wait for our new task row to materialize after startTask returns no id (ms). */ correlateTimeoutMs?: number; + /** How old a persisted pending approval must be before the watch reads it as a wedge (ms). The margin + * keeps a just-raised request that Bob is still resolving from aborting a healthy turn. */ + approvalWedgeMs?: number; } /** @@ -133,6 +143,7 @@ export class InProcessDriver implements BobDriver { private readonly pollMs: number; private readonly quietMs: number; private readonly correlateTimeoutMs: number; + private readonly approvalWedgeMs: number; constructor( private readonly host: Bob2Host, @@ -141,6 +152,7 @@ export class InProcessDriver implements BobDriver { this.pollMs = opts.pollMs ?? DEFAULT_POLL_MS; this.quietMs = opts.quietMs ?? DEFAULT_QUIET_MS; this.correlateTimeoutMs = opts.correlateTimeoutMs ?? 15_000; + this.approvalWedgeMs = opts.approvalWedgeMs ?? 5_000; } /** Resolve the in-process handle and apply auto-approve once. Throws when startTask isn't reachable @@ -232,6 +244,14 @@ export class InProcessDriver implements BobDriver { // mutate the user's global config as a side effect. const dir = this.host.workspaceFolder(); if (!dir) return fail("no open workspace folder to dispatch into"); + // Trust preflight (2.0.2): an untrusted folder runs on Bob's pristine defaults — auto-approve OFF and + // workspace custom modes hidden — so the dispatch would wedge on its first tool or throw "Mode not + // found". Fail fast with the fix instead. Only an explicit `false` fails; null/absent = unknown → proceed. + if (this.host.workspaceTrusted?.() === false) { + return fail( + `workspace is not trusted — Bob ignores the auto-approve config and workspace modes in an untrusted folder; trust ${dir} in the Bob window, then re-dispatch`, + ); + } if (!this.handle) { try { await this.connect(); @@ -276,15 +296,42 @@ export class InProcessDriver implements BobDriver { return fail("dispatched task did not appear in bob.db (could not correlate)"); } this.rememberOwn(id); // ours, not a user chat — so the defer signal won't pause on our own dispatch + // The watch extends the default settle rule with a wedge probe over 2.0.2's task_pending_approvals: + // a persisted approval older than approvalWedgeMs means Bob is frozen on a prompt auto-approve didn't + // cover (an unverifiable command, or the deliberately un-approved `ask`), so abort NOW with the prompt + // named instead of burning the dispatch timeout. Real settle is checked first: a finished turn with a + // stale leftover approval row still reports its true outcome. No-op on a pre-2.0.2 store ([] always). + let wedge: string | null = null; + const boundStore = store; const { settled, row, maxGapMs } = await awaitTurnSettled(store, id, { pollMs: this.pollMs, quietMs: this.quietMs, timeoutMs: opts.timeoutMs ?? 300_000, + isSettled: (r) => { + if (turnSettled(r, this.quietMs)) return true; + const p = boundStore + .pendingApprovals(id) + .find((a) => Date.now() - (a.created_at ?? 0) >= this.approvalWedgeMs); + if (!p) return false; + wedge = describePendingApproval(p.payload_json) ?? "unknown tool request"; + return true; + }, }); // Enrich the outcome from bob.db — output tokens (any outcome) + Bob's summary text (only on a // clean completion; a timeout/error row's last message would be partial/misleading). maxGapMs is // stall-watchdog telemetry (see DispatchResult.maxIdleMs). const tokensUsed = parseCosts(row?.costs ?? null)?.output ?? 0; + if (wedge) { + return { + taskId: id, + result: "", + lastText: `bob2 status=${row?.status ?? "?"} wedged on an approval prompt (${wedge}) — auto-approve does not cover it; resolve it in the Bob window`, + status: "aborted", + tokensUsed, + turns: 0, + maxIdleMs: maxGapMs, + }; + } const done = settled && !!row && !taskError(row); const result = done ? (store.readResultText(id) ?? "") : ""; // Review mode: findings span the task's assistant messages and readResultText returns only the last diff --git a/src/bob2-host.test.ts b/src/bob2-host.test.ts index 95e9799..e31df66 100644 --- a/src/bob2-host.test.ts +++ b/src/bob2-host.test.ts @@ -41,6 +41,12 @@ test("workspaceFolderObject() returns the first WorkspaceFolder object (for star assert.equal(createBob2Host(deps({ folder: null })).workspaceFolderObject(), null); }); +test("workspaceTrusted() reflects the isTrusted dep; null (unknown) when an older extension omits it", () => { + assert.equal(createBob2Host({ ...deps(), isTrusted: () => true }).workspaceTrusted?.(), true); + assert.equal(createBob2Host({ ...deps(), isTrusted: () => false }).workspaceTrusted?.(), false); + assert.equal(createBob2Host(deps()).workspaceTrusted?.(), null); // dep absent → unknown, NOT untrusted +}); + test("uses the configured extension id", () => { const seen: string[] = []; const host = createBob2Host( diff --git a/src/bob2-host.ts b/src/bob2-host.ts index 5d8dcaa..3a0c7e7 100644 --- a/src/bob2-host.ts +++ b/src/bob2-host.ts @@ -10,6 +10,8 @@ export interface VscodeBob2Deps { getExtension(id: string): { isActive?: boolean; exports?: unknown } | undefined; /** vscode.workspace.workspaceFolders — the open folders (first one is the dispatch target). */ workspaceFolders(): readonly { uri: { fsPath: string } }[] | undefined; + /** vscode.workspace.isTrusted — optional so an older extension build keeps working (absent = unknown). */ + isTrusted?(): boolean; } /** Bob 2.0's extension id — the sibling extension whose exported activate() API the driver calls. */ @@ -37,5 +39,9 @@ export function createBob2Host(deps: VscodeBob2Deps, extensionId = BOB2_EXTENSIO // The genuine vscode.WorkspaceFolder, passed through verbatim to startTask (Bob reads `.uri.fsPath`). return deps.workspaceFolders()?.[0] ?? null; }, + workspaceTrusted(): boolean | null { + // null (unknown) when the extension build predates the dep — the driver only hard-fails on `false`. + return deps.isTrusted ? deps.isTrusted() : null; + }, }; } diff --git a/src/bob2-taskstore.test.ts b/src/bob2-taskstore.test.ts index a87fed1..345ef7f 100644 --- a/src/bob2-taskstore.test.ts +++ b/src/bob2-taskstore.test.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { Bob2TaskStore, awaitTurnSettled, + describePendingApproval, isTerminal, isActivelyRunning, taskError, @@ -445,3 +446,38 @@ test("bob2DbExists reports whether the store file exists", () => { rmSync(dir, { recursive: true, force: true }); } }); + +// ── 2.0.2: persisted pending approvals ──────────────────────────────────────────────────────────── + +test("pendingApprovals returns [] on a pre-2.0.2 store (no table) — probed, never thrown", () => { + const { store } = makeStore(); // makeStore builds the 2.0.0/2.0.1 schema: no task_pending_approvals + assert.deepEqual(store.pendingApprovals("t1"), []); + assert.deepEqual(store.pendingApprovals("t1"), []); // second call hits the memoized probe +}); + +test("pendingApprovals reads a task's rows oldest-first, scoped to that task", () => { + const { db, store } = makeStore(); + db.exec( + "CREATE TABLE task_pending_approvals (task_id TEXT NOT NULL, request_id TEXT NOT NULL, payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (task_id, request_id))", + ); + const ins = db.prepare("INSERT INTO task_pending_approvals VALUES (?, ?, ?, ?)"); + ins.run("t1", "r2", '{"permission":"execute"}', 200); + ins.run("t1", "r1", '{"permission":"ask"}', 100); + ins.run("t2", "r3", '{"permission":"edit"}', 50); // another task's prompt — not ours + assert.deepEqual( + store.pendingApprovals("t1").map((p) => p.request_id), + ["r1", "r2"], + ); + assert.deepEqual(store.pendingApprovals("t3"), []); +}); + +test("describePendingApproval summarizes tool + permission; degrades on partial/garbage payloads", () => { + assert.equal( + describePendingApproval('{"requestId":"x","signature":{"name":"execute_command"},"permission":"execute"}'), + "execute_command (execute)", + ); + assert.equal(describePendingApproval('{"signature":{"name":"ask_followup_question"}}'), "ask_followup_question"); + assert.equal(describePendingApproval('{"permission":"ask"}'), "ask"); // no tool name → the permission alone + assert.equal(describePendingApproval('{"other":true}'), null); + assert.equal(describePendingApproval("{not json"), null); +}); diff --git a/src/bob2-taskstore.ts b/src/bob2-taskstore.ts index 2d56544..7b76d4f 100644 --- a/src/bob2-taskstore.ts +++ b/src/bob2-taskstore.ts @@ -136,6 +136,28 @@ export function firstMessageMatches(firstMessage: string | null | undefined, con return a.includes(b); // full-content containment (a === b is subsumed) } +/** A row of 2.0.2's `task_pending_approvals`: a tool request auto-approve did NOT cover, persisted while + * the task sits frozen waiting for the user. Absent as a table on 2.0.0/2.0.1 stores. */ +export interface Bob2PendingApproval { + request_id: string; + payload_json: string; + created_at: number | null; +} + +/** Human summary of a pending approval's payload (live shape: {requestId, signature:{name,…}, permission,…}) + * → "execute_command (execute)"; null when the JSON won't parse or names nothing. Best-effort — the payload + * is Bob's serialized UI request, so shape drift must degrade the message, not the abort. */ +export function describePendingApproval(payloadJson: string): string | null { + try { + const p = JSON.parse(payloadJson) as { signature?: { name?: unknown }; permission?: unknown }; + const name = typeof p.signature?.name === "string" ? p.signature.name : null; + const perm = typeof p.permission === "string" ? p.permission : null; + return name ? (perm ? `${name} (${perm})` : name) : perm; + } catch { + return null; + } +} + /** The `workspace` fsPath out of a task's `env` JSON (live shape: {id,workspace,scheme,…}), or null. */ function envWorkspace(env: string | null): string | null { if (!env) return null; @@ -172,6 +194,8 @@ export class Bob2TaskStore { // Prepared statements are memoized: the completion watch reads the same row once per poll (hundreds of // times over a long dispatch), so re-preparing each call would re-parse the SQL every poll. private readonly stmts = new Map(); + // Does this store have 2.0.2's task_pending_approvals table? Probed once per open (null = not yet). + private hasPendingApprovals: boolean | null = null; constructor(private readonly db: DatabaseSync) {} @@ -269,6 +293,24 @@ export class Bob2TaskStore { return { running, activeRecently }; } + /** The task's persisted pending approvals, oldest first (2.0.2+). A non-empty result means Bob is frozen + * on a tool request the auto-approve config didn't cover — the wedge the driver aborts fast on instead + * of burning its dispatch timeout. [] on a pre-2.0.2 store (no table, probed once) or any read fault: + * the completion watch polls this, so a fault must degrade to "no wedge", never throw. */ + pendingApprovals(taskId: string): Bob2PendingApproval[] { + try { + this.hasPendingApprovals ??= !!this.q( + "SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'task_pending_approvals'", + ).get(); + if (!this.hasPendingApprovals) return []; + return this.q( + "SELECT request_id, payload_json, created_at FROM task_pending_approvals WHERE task_id = ? ORDER BY created_at ASC", + ).all(taskId) as unknown as Bob2PendingApproval[]; + } catch { + return []; + } + } + /** Bob's completion summary for the task: the latest `assistant` message's `content` (the result text * 2.0 doesn't return from startTask). Read from the `messages` table (id/task_id/role/data JSON). * Best-effort — null when no assistant message exists or the row won't parse (never throws). */ @@ -314,14 +356,27 @@ export class Bob2TaskStore { } /** - * Poll a task row until the dispatched turn settles, or the wall-clock elapses (`settled:false` → the - * driver maps that to a 'timeout'). Polling, not events (2.0 exposes none); pollMs trades latency for DB - * load. Settle rule (live-validated against the active→running→active lifecycle): + * The default settle rule (live-validated against the active→running→active lifecycle): * - a real `last_error`, or a terminal status, settles immediately; * - otherwise the turn is done once it is NOT running, HAS run (updated_at advanced past created_at, so * we don't settle a created-but-unstarted row), AND has been quiet for `quietMs` (updated_at still). - * Gating on 'running' is what makes the multi-second updated_at gaps *within* a turn safe; `quietMs` only - * governs the not-running tail (post-turn, or a fast turn we never caught 'running'). Override via opts.isSettled. + * Exported so a custom opts.isSettled (e.g. the driver's approval-wedge probe) can EXTEND the rule + * rather than re-encode it. + */ +export function turnSettled(row: Bob2TaskRow, quietMs: number): boolean { + return ( + taskError(row) != null || + isTerminal(row.status) || + (!isActivelyRunning(row.status) && hasRun(row) && Date.now() - (row.updated_at ?? 0) >= quietMs) + ); +} + +/** + * Poll a task row until the dispatched turn settles, or the wall-clock elapses (`settled:false` → the + * driver maps that to a 'timeout'). Polling, not events (2.0 exposes none); pollMs trades latency for DB + * load. Settle rule: `turnSettled` — gating on 'running' is what makes the multi-second updated_at gaps + * *within* a turn safe; `quietMs` only governs the not-running tail (post-turn, or a fast turn we never + * caught 'running'). Override via opts.isSettled. */ export async function awaitTurnSettled( store: Bob2TaskStore, @@ -346,11 +401,7 @@ export async function awaitTurnSettled( maxGapMs = Math.max(maxGapMs, u - prevUpdated); if (u != null) prevUpdated = u; prevRunning = isActivelyRunning(row.status); - const settled = opts.isSettled - ? opts.isSettled(row) - : taskError(row) != null || - isTerminal(row.status) || - (!isActivelyRunning(row.status) && hasRun(row) && Date.now() - (row.updated_at ?? 0) >= quietMs); + const settled = opts.isSettled ? opts.isSettled(row) : turnSettled(row, quietMs); if (settled) return { settled: true, row, maxGapMs }; } if (Date.now() >= deadline) return { settled: false, row, maxGapMs }; From 189580a9e881679d03beba4be37d5331dbf06cc4 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:23:28 -0700 Subject: [PATCH 2/6] release: 2.3.0 --- CHANGELOG.md | 23 +++++++ claude-plugin/.claude-plugin/plugin.json | 2 +- extension/package-lock.json | 4 +- extension/package.json | 82 +++++++++++++++++++----- package-lock.json | 4 +- package.json | 2 +- 6 files changed, 95 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 605b1b8..0bad747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are [SemVer](https://semver.org/). +## [2.3.0] — 2026-08-07 — Bob 2.0.2 awareness: trust preflight + approval-wedge fast-abort + +Bob 2.0.2 changes two things a headless dispatcher must know about: an **untrusted workspace** now runs +on Bob's pristine defaults (auto-approve OFF, command security ON, workspace custom modes hidden) with +`~/.bob/settings/settings.json` ignored, and a tool request auto-approve doesn't cover is now **persisted +to bob.db** (`task_pending_approvals`) while the task sits frozen on it. Every other contract the driver +relies on (startTask, tasks/messages schema, lifecycle, mode resolution, settings keys) is unchanged — +verified against the 2.0.2 bundle. + +### Added + +- **Workspace-trust preflight.** The in-process driver fails a dispatch up front — before the settings.json + auto-approve write — when the window's workspace is untrusted, naming the folder to trust, instead of + wedging on the first tool prompt or throwing "Mode not found" on a workspace custom mode. Trust flows + live from `vscode.workspace.isTrusted` through a new optional host seam; an older extension build that + doesn't supply it reads as unknown and keeps the pre-2.0.2 behavior. +- **Approval-wedge fast-abort.** The completion watch also polls the task's `task_pending_approvals` rows: + a persisted approval older than a small margin (`approvalWedgeMs`, default 5s) means Bob is frozen on a + prompt the auto-approve config didn't cover (an unverifiable command, or the deliberately un-approved + `ask`), so the dispatch aborts immediately with the tool named — e.g. `execute_command (execute)` — + instead of burning the full dispatch timeout (default 5 min). A finished turn still reports its true + outcome even with a stale approval row behind it, and a pre-2.0.2 store (no table) is a no-op. + ## [2.2.0] — 2026-07-09 — worker webhook + drainer health signal ### Added diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 042d8c8..8dcffbe 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "bob-companion", "displayName": "Bob Companion", "description": "Use Claude Code from any repo as the foreman and worker for the IBM Bob task board: provision, route, triage, and drain tasks Bob shares. Ships a self-contained MCP server.", - "version": "2.2.0", + "version": "2.3.0", "author": { "name": "Joshua Gilbert" }, diff --git a/extension/package-lock.json b/extension/package-lock.json index e188ebe..2a0f871 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "bob-tasks", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bob-tasks", - "version": "2.2.0", + "version": "2.3.0", "devDependencies": { "@types/node": "^22.0.0", "@types/vscode": "^1.94.0", diff --git a/extension/package.json b/extension/package.json index 8754475..d142a3d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,13 +2,20 @@ "name": "bob-tasks", "displayName": "Bob Tasks", "description": "Auto-dispatch queued tasks to IBM Bob, with mode routing, a safety gate, defer-while-chatting, and native notifications.", - "version": "2.2.0", + "version": "2.3.0", "publisher": "local", "license": "Apache-2.0", - "engines": { "vscode": "^1.94.0" }, - "categories": ["Other"], + "engines": { + "vscode": "^1.94.0" + }, + "categories": [ + "Other" + ], "icon": "icon.png", - "activationEvents": ["onStartupFinished", "onUri"], + "activationEvents": [ + "onStartupFinished", + "onUri" + ], "main": "./out/extension.js", "contributes": { "configuration": { @@ -45,24 +52,55 @@ }, "bobTasks.maxRisk": { "type": "string", - "enum": ["safe", "standard", "elevated"], + "enum": [ + "safe", + "standard", + "elevated" + ], "default": "standard", "description": "Only auto-dispatch tasks whose mode risk is at or below this. 'advanced' (browser/commands) is elevated." }, - "bobTasks.pollMs": { "type": "number", "default": 3000, "description": "Idle poll interval (ms)." }, - "bobTasks.timeoutMs": { "type": "number", "default": 300000, "description": "Per-task dispatch timeout (ms)." }, - "bobTasks.assignee": { "type": "string", "default": "bob", "description": "Assignee recorded when the worker claims a task." }, - "bobTasks.tag": { "type": "string", "default": "", "description": "Only process tasks with this tag. Empty = all tasks." }, - "bobTasks.autoStart": { "type": "boolean", "default": false, "description": "Start the worker automatically when Bob launches." }, + "bobTasks.pollMs": { + "type": "number", + "default": 3000, + "description": "Idle poll interval (ms)." + }, + "bobTasks.timeoutMs": { + "type": "number", + "default": 300000, + "description": "Per-task dispatch timeout (ms)." + }, + "bobTasks.assignee": { + "type": "string", + "default": "bob", + "description": "Assignee recorded when the worker claims a task." + }, + "bobTasks.tag": { + "type": "string", + "default": "", + "description": "Only process tasks with this tag. Empty = all tasks." + }, + "bobTasks.autoStart": { + "type": "boolean", + "default": false, + "description": "Start the worker automatically when Bob launches." + }, "bobTasks.autoApproveGlobal": { "type": "boolean", "default": true, "description": "On the first 2.0 dispatch, write Bob's headless auto-approve into its GLOBAL settings (~/.bob/settings/settings.json) so queued tasks run unattended. This disables Bob's command security for every Bob window/project for your user and persists until changed (a one-time notice is shown the first time). Turn off to keep Bob's normal approval prompts — auto-dispatch will then stall on the first prompt." }, - "bobTasks.notify.enabled": { "type": "boolean", "default": true, "description": "Show a notification when a task finishes." }, + "bobTasks.notify.enabled": { + "type": "boolean", + "default": true, + "description": "Show a notification when a task finishes." + }, "bobTasks.dispatch.surface": { "type": "string", - "enum": ["sidebar", "newTab"], + "enum": [ + "sidebar", + "newTab" + ], "default": "sidebar", "description": "Where dispatched tasks render. 'sidebar' = quiet same-tab; 'newTab' = isolated editor tab (steals focus)." }, @@ -103,7 +141,10 @@ }, "bobTasks.classifierBackend": { "type": "string", - "enum": ["cli", "api"], + "enum": [ + "cli", + "api" + ], "default": "cli", "enumDescriptions": [ "Run the installed `claude` CLI headless — reuses your Claude login (no API key), Sonnet-grade judgment, but each call is heavier (~full-agent cost).", @@ -159,9 +200,18 @@ } }, "commands": [ - { "command": "bobTasks.startWorker", "title": "Bob Tasks: Start Worker" }, - { "command": "bobTasks.stopWorker", "title": "Bob Tasks: Stop Worker" }, - { "command": "bobTasks.toggleWorker", "title": "Bob Tasks: Toggle Worker" } + { + "command": "bobTasks.startWorker", + "title": "Bob Tasks: Start Worker" + }, + { + "command": "bobTasks.stopWorker", + "title": "Bob Tasks: Stop Worker" + }, + { + "command": "bobTasks.toggleWorker", + "title": "Bob Tasks: Toggle Worker" + } ] }, "scripts": { diff --git a/package-lock.json b/package-lock.json index 9e0546d..ab64295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bob-control", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bob-control", - "version": "2.2.0", + "version": "2.3.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index 1708ee2..a59036e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pounceai/bob-control", - "version": "2.2.0", + "version": "2.3.0", "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board.", "author": "Joshua Gilbert", "license": "Apache-2.0", From 0987a32cd2fc055adbd3e208a7900c68947ba734 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:28:30 -0700 Subject: [PATCH 3/6] style(driver): tell the 2.0.2 trust and wedge stories once each --- CHANGELOG.md | 33 +++++++++++++++------------------ src/bob2-driver.ts | 20 +++++++------------- src/bob2-taskstore.ts | 11 +++++------ 3 files changed, 27 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bad747..072b494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,28 +3,25 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are [SemVer](https://semver.org/). -## [2.3.0] — 2026-08-07 — Bob 2.0.2 awareness: trust preflight + approval-wedge fast-abort +## [2.3.0] — 2026-08-07 — Bob 2.0.2: trust preflight + approval-wedge fast-abort -Bob 2.0.2 changes two things a headless dispatcher must know about: an **untrusted workspace** now runs -on Bob's pristine defaults (auto-approve OFF, command security ON, workspace custom modes hidden) with -`~/.bob/settings/settings.json` ignored, and a tool request auto-approve doesn't cover is now **persisted -to bob.db** (`task_pending_approvals`) while the task sits frozen on it. Every other contract the driver -relies on (startTask, tasks/messages schema, lifecycle, mode resolution, settings keys) is unchanged — -verified against the 2.0.2 bundle. +Verified against the 2.0.2 bundle: every contract the driver relies on (startTask, tasks/messages schema, +lifecycle, mode resolution, settings keys) is unchanged; what 2.0.2 adds is the trust gate and the +pending-approval persistence below. ### Added -- **Workspace-trust preflight.** The in-process driver fails a dispatch up front — before the settings.json - auto-approve write — when the window's workspace is untrusted, naming the folder to trust, instead of - wedging on the first tool prompt or throwing "Mode not found" on a workspace custom mode. Trust flows - live from `vscode.workspace.isTrusted` through a new optional host seam; an older extension build that - doesn't supply it reads as unknown and keeps the pre-2.0.2 behavior. -- **Approval-wedge fast-abort.** The completion watch also polls the task's `task_pending_approvals` rows: - a persisted approval older than a small margin (`approvalWedgeMs`, default 5s) means Bob is frozen on a - prompt the auto-approve config didn't cover (an unverifiable command, or the deliberately un-approved - `ask`), so the dispatch aborts immediately with the tool named — e.g. `execute_command (execute)` — - instead of burning the full dispatch timeout (default 5 min). A finished turn still reports its true - outcome even with a stale approval row behind it, and a pre-2.0.2 store (no table) is a no-op. +- **Workspace-trust preflight.** Bob 2.0.2 runs an untrusted workspace on pristine defaults — auto-approve + OFF, workspace custom modes hidden, `~/.bob/settings/settings.json` ignored — so a headless dispatch + there wedges on its first tool prompt or throws "Mode not found". The driver now fails such a dispatch + up front (before the settings.json auto-approve write), naming the folder to trust. Trust flows live + from `vscode.workspace.isTrusted` through an optional host seam; an older extension build that doesn't + supply it reads as unknown and keeps the pre-2.0.2 behavior. +- **Approval-wedge fast-abort.** 2.0.2 persists a tool request auto-approve didn't cover to bob.db + (`task_pending_approvals`) while the task sits frozen on it. The completion watch polls those rows: an + approval older than `approvalWedgeMs` (default 5s) aborts the dispatch immediately with the tool named — + e.g. `execute_command (execute)` — instead of burning the dispatch timeout (default 5 min). A finished + turn still reports its true outcome past a stale approval row; a pre-2.0.2 store (no table) is a no-op. ## [2.2.0] — 2026-07-09 — worker webhook + drainer health signal diff --git a/src/bob2-driver.ts b/src/bob2-driver.ts index 7151058..7a08b15 100644 --- a/src/bob2-driver.ts +++ b/src/bob2-driver.ts @@ -59,10 +59,8 @@ export interface Bob2Host { /** Bob's open folder as the genuine `vscode.WorkspaceFolder` to pass to startTask (Bob reads `.uri.fsPath` * off it). Opaque so the driver carries no `vscode` type; null when none open. */ workspaceFolderObject(): unknown; - /** `vscode.workspace.isTrusted`, or null when unknown (older extension build). Optional so existing host - * implementations keep compiling; the dispatch preflight hard-fails only on an explicit `false` — - * Bob 2.0.2 runs an untrusted folder on pristine defaults (auto-approve OFF, workspace modes hidden), - * so a headless dispatch there would wedge on its first tool. */ + /** `vscode.workspace.isTrusted`, or null when unknown (older extension build). Optional so existing + * hosts keep compiling; the dispatch preflight hard-fails only on an explicit `false`. */ workspaceTrusted?(): boolean | null; } @@ -296,22 +294,18 @@ export class InProcessDriver implements BobDriver { return fail("dispatched task did not appear in bob.db (could not correlate)"); } this.rememberOwn(id); // ours, not a user chat — so the defer signal won't pause on our own dispatch - // The watch extends the default settle rule with a wedge probe over 2.0.2's task_pending_approvals: - // a persisted approval older than approvalWedgeMs means Bob is frozen on a prompt auto-approve didn't - // cover (an unverifiable command, or the deliberately un-approved `ask`), so abort NOW with the prompt - // named instead of burning the dispatch timeout. Real settle is checked first: a finished turn with a - // stale leftover approval row still reports its true outcome. No-op on a pre-2.0.2 store ([] always). + // Wedge probe (2.0.2): a persisted approval older than approvalWedgeMs means Bob is frozen on a + // prompt auto-approve didn't cover (an unverifiable command, or the deliberately un-approved `ask`) + // — abort with it named instead of burning the dispatch timeout. Real settle wins first, so a + // finished turn with a stale approval row behind it still reports its true outcome. let wedge: string | null = null; - const boundStore = store; const { settled, row, maxGapMs } = await awaitTurnSettled(store, id, { pollMs: this.pollMs, quietMs: this.quietMs, timeoutMs: opts.timeoutMs ?? 300_000, isSettled: (r) => { if (turnSettled(r, this.quietMs)) return true; - const p = boundStore - .pendingApprovals(id) - .find((a) => Date.now() - (a.created_at ?? 0) >= this.approvalWedgeMs); + const p = store!.pendingApprovals(id).find((a) => Date.now() - (a.created_at ?? 0) >= this.approvalWedgeMs); if (!p) return false; wedge = describePendingApproval(p.payload_json) ?? "unknown tool request"; return true; diff --git a/src/bob2-taskstore.ts b/src/bob2-taskstore.ts index 7b76d4f..c0fa96c 100644 --- a/src/bob2-taskstore.ts +++ b/src/bob2-taskstore.ts @@ -145,8 +145,8 @@ export interface Bob2PendingApproval { } /** Human summary of a pending approval's payload (live shape: {requestId, signature:{name,…}, permission,…}) - * → "execute_command (execute)"; null when the JSON won't parse or names nothing. Best-effort — the payload - * is Bob's serialized UI request, so shape drift must degrade the message, not the abort. */ + * → "execute_command (execute)"; null when the JSON won't parse or names nothing. The payload is Bob's + * serialized UI request, so shape drift degrades the message, never the abort. */ export function describePendingApproval(payloadJson: string): string | null { try { const p = JSON.parse(payloadJson) as { signature?: { name?: unknown }; permission?: unknown }; @@ -293,10 +293,9 @@ export class Bob2TaskStore { return { running, activeRecently }; } - /** The task's persisted pending approvals, oldest first (2.0.2+). A non-empty result means Bob is frozen - * on a tool request the auto-approve config didn't cover — the wedge the driver aborts fast on instead - * of burning its dispatch timeout. [] on a pre-2.0.2 store (no table, probed once) or any read fault: - * the completion watch polls this, so a fault must degrade to "no wedge", never throw. */ + /** The task's persisted pending approvals, oldest first (2.0.2+; the driver's wedge probe). [] on a + * pre-2.0.2 store (no table, probed once) or any read fault — the completion watch polls this, so a + * fault must degrade to "no wedge", never throw. */ pendingApprovals(taskId: string): Bob2PendingApproval[] { try { this.hasPendingApprovals ??= !!this.q( From 0d1279b558b642db7c837b9ba8f16b979fc13571 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:28:30 -0700 Subject: [PATCH 4/6] chore: track the LobeHub manifest, synced to 2.3.0 --- lhm.plugin.json | 712 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 712 insertions(+) create mode 100644 lhm.plugin.json diff --git a/lhm.plugin.json b/lhm.plugin.json new file mode 100644 index 0000000..0a50a9a --- /dev/null +++ b/lhm.plugin.json @@ -0,0 +1,712 @@ +{ + "identifier": "pounceai-bob-control", + "name": "Bob Control", + "version": "2.3.0", + "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board. The board path can be configured via environment variables such as BOB_TASKS_DB, BOB_TASKS_PORTABLE, or BOB_TASKS_WORKTREE_SHARED.", + "author": "PounceAI", + "authorUrl": "https://github.com/PounceAI", + "homepage": "https://github.com/PounceAI/bob-control", + "category": "developer", + "tags": [ + "mcp", + "task-board", + "agent-orchestration", + "ibm-bob", + "sqlite", + "worker", + "cli", + "automation" + ], + "tools": [ + { + "name": "create_task", + "title": "Create Task", + "description": "Provision a new task for Bob to work on. Returns the created task including its id.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Short, action-oriented task title" + }, + "description": { + "type": "string", + "description": "Detailed instructions, context, and acceptance criteria" + }, + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "urgent" + ], + "description": "Priority bucket (default: medium)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels for filtering, e.g. ['rpg','refactor']" + }, + "mode": { + "type": "string", + "description": "Bob mode to run this task in: 'code' | 'advanced' (adds MCP/Browser) | 'ask' (read-only) | 'orchestrator', or a custom mode slug. Omit to let the dispatcher auto-route from the task content." + }, + "depends_on": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Task IDs this task depends on (all must be 'done' before this task is eligible)" + }, + "staged": { + "type": "boolean", + "description": "Create the task non-pullable ('staged') so a running worker can't grab it mid-curation. Release later with release_tasks. Use for bulk-create + triage." + } + }, + "required": [ + "title" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "list_tasks", + "title": "List Tasks", + "description": "List tasks, optionally filtered by status and/or tag. Ordered by priority, then oldest first.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "staged", + "pending", + "in_progress", + "needs_input", + "blocked", + "analysis_done", + "done", + "cancelled" + ] + }, + "tag": { + "type": "string" + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get_task", + "title": "Get Task", + "description": "Get full details of a single task, including its notes / work log.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Task id" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get_next_task", + "title": "Get Next Task", + "description": "Fetch the highest-priority pending task. Optionally filter by tag, and optionally claim it (mark in_progress + assign) in the same call.", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "claim": { + "type": "boolean", + "description": "If true, immediately mark the task in_progress and assign it" + }, + "assignee": { + "type": "string", + "description": "Who is taking the task (default: 'bob')" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "predict_mode", + "title": "Predict Mode", + "description": "Preview which Bob mode a task dispatches in, computed from the connector's router (modes.ts) \u2014 the single source of truth, so callers never re-encode the keyword table. Pass `id` to route an existing task, or `text` to route a hypothetical task (the text is treated as the title). Resolution order (first match wins): an explicit `mode` \u203a a tag naming a mode \u203a the keyword auto-router \u203a `code`. Returns {mode, source: explicit|tag|auto-router|default, risk: safe|standard|elevated}. Risk gates dispatch \u2014 the worker auto-runs only at/below its --max-risk (default standard), so an `advanced` (elevated) task waits for manual dispatch.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Route an existing task by id" + }, + "text": { + "type": "string", + "description": "Route hypothetical task text (treated as the task title) instead of an id" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "claim_task", + "title": "Claim Task", + "description": "Mark a task as in_progress and assign it to an owner.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "assignee": { + "type": "string", + "description": "Owner (default: 'bob')" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "update_task_status", + "title": "Update Task Status", + "description": "Change a task's status (pending | in_progress | blocked | done | cancelled).", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "staged", + "pending", + "in_progress", + "needs_input", + "blocked", + "analysis_done", + "done", + "cancelled" + ] + } + }, + "required": [ + "id", + "status" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "add_task_note", + "title": "Add Task Note", + "description": "Append a progress note / work-log entry to a task.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "note": { + "type": "string", + "minLength": 1 + }, + "author": { + "type": "string", + "description": "Note author (default: 'bob')" + } + }, + "required": [ + "id", + "note" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "submit_result", + "title": "Submit Result", + "description": "Attach a result to a task and complete it. A read-only run (ask/plan/review mode) terminates as 'analysis_done', not 'done'. To reach 'done' on an implementation task, pass `evidence` (files changed / commit / test result); without evidence it lands in 'analysis_done'. Pass mark_done:false to just attach the result without completing.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "result": { + "type": "string", + "minLength": 1 + }, + "mark_done": { + "type": "boolean", + "description": "Complete the task (default: true). false = attach result only." + }, + "evidence": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths created/changed (recorded as artifacts)" + }, + "files_changed": { + "type": "integer" + }, + "commit": { + "type": "string", + "description": "Commit sha produced" + }, + "test": { + "type": "string", + "description": "Verification result, e.g. 'npm test: 42 passed'" + }, + "diffstat": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "Proof of execution required for an implementation task to reach 'done'." + } + }, + "required": [ + "id", + "result" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "ask_question", + "title": "Ask a Question (needs human input)", + "description": "Raise a question for a human when you lack a value you need \u2014 NEVER guess or fabricate. Parks the task as 'needs_input' and writes the question to the board (visible via get_task and board_report). Returns a question_id; then call await_answer to wait for the reply.", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "integer" + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The question to ask the human" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional multiple-choice answers" + }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 86400000, + "description": "How long to wait before the question times out and the task parks blocked (default 30m, max 24h)" + } + }, + "required": [ + "task_id", + "text" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "answer_task_question", + "title": "Answer a Task's Question", + "description": "Answer a question a worker raised (see needs_input tasks / board_report). Matched by question_id so a stale answer can't apply to a new question. Records the answer and resumes the waiting worker (task returns to in_progress).", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "integer" + }, + "question_id": { + "type": "string", + "description": "The question_id from get_task.pending_question / board_report" + }, + "answer": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "task_id", + "question_id", + "answer" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "await_answer", + "title": "Await an Answer (worker blocks here)", + "description": "Block until the question is answered, or report back so you can call again. Returns {status:'answered', answer} when answered, {status:'timed_out'} once past the deadline (the task is then parked blocked \u2014 do NOT proceed or guess), or {status:'waiting'} after the poll window (call await_answer again). The worker's wait loop after ask_question.", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "integer" + }, + "question_id": { + "type": "string" + }, + "wait_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Per-call poll window (default 25000ms, capped 55000ms)" + } + }, + "required": [ + "task_id", + "question_id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "await_task", + "title": "Await Task Completion (blocks here)", + "description": "Block until task #task_id settles, then return it \u2014 the way to 'hook' back into your turn the moment Bob finishes. Returns {status:'done'|'analysis_done', result} on success, {status:'blocked'|'cancelled', result} when Bob stopped without completing, {status:'needs_input', question} when Bob is waiting on a board question (answer it with answer_task_question, then call await_task again), or {status:'waiting', current} after the poll window (call await_task again). Polls the shared board; the result is written by Bob's worker. Use after dispatching a task you want to act on as soon as it's done. PREFER this over looping on list_tasks/board_status/get_task to watch a task \u2014 it blocks server-side until the settling write lands and hands back needs_input questions to answer, so you should NOT poll the board by hand. Requires something draining the board \u2014 a 1.x worker process or the 2.0 in-process loop; check board_status.worker_draining first (it reflects both).", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "integer" + }, + "wait_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Per-call poll window (default 25000ms, capped 55000ms)" + } + }, + "required": [ + "task_id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "set_task_mode", + "title": "Set Task Mode", + "description": "Set or clear a task's Bob mode slug. Pass an empty string to clear it and let the dispatcher auto-route.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "mode": { + "type": "string", + "description": "Mode slug ('code' | 'advanced' | 'ask' | 'orchestrator' | custom), or '' to clear" + } + }, + "required": [ + "id", + "mode" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "set_task_dependencies", + "title": "Set Task Dependencies", + "description": "Set or clear a task's dependencies. All dependencies must be 'done' before the task is eligible. Pass an empty array to clear dependencies.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Task id" + }, + "depends_on": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Task IDs this task depends on (empty array clears dependencies)" + } + }, + "required": [ + "id", + "depends_on" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "delete_task", + "title": "Delete Task", + "description": "Delete a task and its notes. DELETE IS NOT UNDO: if the task already ran and recorded artifacts (files written, commits), this refuses and lists the orphaned paths unless force:true (delete the record anyway) or cleanup:true (also remove the files). Prefer update_task_status 'cancelled' to keep a record.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Task id" + }, + "force": { + "type": "boolean", + "description": "Delete the record even if it has recorded artifacts" + }, + "cleanup": { + "type": "boolean", + "description": "Also unlink the files the task wrote, then delete" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "disarm_board", + "title": "Disarm Board (pause dispatch)", + "description": "Pause all dispatch: while disarmed, no worker can pull or claim any task. Use before a bulk-create/triage so nothing is grabbed mid-curation, then arm_board when ready.", + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Why dispatch is paused (shown in board_status)" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "arm_board", + "title": "Arm Board (resume dispatch)", + "description": "Resume dispatch: workers may pull/claim pending tasks again.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "release_tasks", + "title": "Release Staged Tasks", + "description": "Move staged tasks to pending so workers can pull them. Optionally filter by ids and/or tag. With no filter, releases every staged task. The deliberate 'arm' step after curation.", + "inputSchema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only release these task ids" + }, + "tag": { + "type": "string", + "description": "Only release staged tasks carrying this tag" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "board_status", + "title": "Board Status", + "description": "Dispatch state, counts, and the live task list: whether the board is `armed`, task `counts` by status, whether a drainer is currently servicing the board (`worker_draining` \u2014 a live heartbeat from either a 1.x worker process or the 2.0 in-process loop, with `.tags` = the --tag each live worker drains, null = an unfiltered worker that drains all tags, and `.last_dispatch` = the freshest dispatch outcome among live workers ({status, detail, seconds_ago}, null if none yet) \u2014 a health signal beyond mere liveness: a logged-out/failing Bob keeps beating, so a `last_dispatch.status` of 'aborted' means the drainer is alive but not completing work (warn before dispatching)), `worker_leases` (which checkout each live worker owns), and `open_tasks` \u2014 the non-terminal tasks (staged / pending / in_progress / needs_input / blocked) as compact {id,title,status,mode,tags,priority} rows for deduping before create_task (capped; see open_tasks_truncated). Check `worker_draining.draining` before await_task: if false, nothing is draining the board, so don't block \u2014 start a drainer first (open the repo in a Bob 2.0 window, or run a 1.x worker). And if draining is true but no entry in `worker_draining.tags` is null or matches your task's tags, a tag-pinned worker still won't pull it. Also check before a bulk-create.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "record_artifact", + "title": "Record Artifact", + "description": "Record a side effect a worker produced for a task: a file written (kind 'file' + path), a commit (kind 'commit' + detail=sha), or a test result (kind 'test' + detail). Artifacts make delete safe and let an implementation task reach 'done' with evidence.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Task id" + }, + "kind": { + "type": "string", + "enum": [ + "file", + "commit", + "test" + ] + }, + "path": { + "type": "string", + "description": "Filesystem path for kind 'file' (absolute is best, for cleanup)" + }, + "detail": { + "type": "string", + "description": "Commit sha, test summary, or diffstat" + } + }, + "required": [ + "id", + "kind" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "board_report", + "title": "Board Report", + "description": "Render the board as a markdown standup/audit: tasks grouped by status in pull order, each with age, idle time, latest note, and a stalled flag for long-running in_progress work. Optionally filter to one status.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "staged", + "pending", + "in_progress", + "needs_input", + "blocked", + "analysis_done", + "done", + "cancelled" + ], + "description": "Restrict the report to a single status group" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "revert_task", + "title": "Revert a Task (roll back to its checkpoint)", + "description": "Restore the working tree to the task's pre-task checkpoint \u2014 undo what the task changed. Requires a checkpoint, which the worker captures by default but CONSUMES on completion (a failed dispatch preserves its WIP to branch bob/task- instead), so this works only while one still exists. REFUSES if this server's repo isn't the one the task edited, or if HEAD moved since capture (pass force to override). The pre-revert state is pinned to a recovery ref. Restores tracked files (HEAD untouched) and removes files the task created.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "force": { + "type": "boolean", + "description": "Revert even if HEAD moved since the checkpoint" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "forbidden" + } + } + ] +} \ No newline at end of file From bb3b698107488d8b9f30edeb3c7a058ff71a44e8 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:35:29 -0700 Subject: [PATCH 5/6] fix(driver): wedge probe skips a null-timestamp approval row Review follow-ups (board task #141): - The wedge predicate required Date.now() - (created_at ?? 0) >= margin, so a null created_at (impossible per the DDL's NOT NULL, but Bob owns the column) read as infinitely old and instantly aborted a healthy dispatch. It now requires a non-null timestamp - schema drift falls back to the plain dispatch timeout instead of a false abort. Regression test pins it. - The isSettled closure narrowed `store` with a non-null assertion; use a post-guard alias instead so the invariant stays type-checked. - quietMs feeds both the awaitTurnSettled option and the closure's turnSettled from one local, so the two settle paths can't diverge. - The pendingApprovals oldest-first test now builds its store over the complete 2.0.2 schema rather than probing a table created after the store existed. Declined: narrowing Bob2PendingApproval.created_at to number - row types stay wide for Bob-owned columns (the Bob2TaskRow convention); the predicate fix removes the hazard without breaking that. --- src/bob2-driver.test.ts | 17 +++++++++++++++++ src/bob2-driver.ts | 14 ++++++++++---- src/bob2-taskstore.test.ts | 3 ++- src/bob2-taskstore.ts | 3 ++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/bob2-driver.test.ts b/src/bob2-driver.test.ts index be84170..2cb1671 100644 --- a/src/bob2-driver.test.ts +++ b/src/bob2-driver.test.ts @@ -464,6 +464,23 @@ test("dispatch aborts fast on a wedged approval prompt, naming the tool, instead assert.equal(res.taskId, id); }); +test("an approval row with a null created_at (schema drift) is skipped, not read as an instant wedge", async () => { + const { db, store, seedRoot, bump } = makeStore(); + db.exec("DROP TABLE task_pending_approvals"); // recreate WITHOUT the NOT NULL, to simulate the drift + db.exec("CREATE TABLE task_pending_approvals (task_id TEXT, request_id TEXT, payload_json TEXT, created_at INTEGER)"); + let id = ""; + const driver = new InProcessDriver(makeHost({ startTask: () => void (id = seedRoot("running")) }), { + openStore: () => store, + ...fast, + approvalWedgeMs: 10, + }); + setTimeout(() => { + db.prepare("INSERT INTO task_pending_approvals VALUES (?, 'r', '{}', NULL)").run(id); + bump(id, "active"); // the turn finishes — a null-aged row must not have aborted it meanwhile + }, 12); + assert.equal((await driver.dispatch({ text: "do it" })).status, "completed"); +}); + test("a fresh approval inside the wedge margin does not abort a turn that then completes", async () => { const { store, seedRoot, bump, seedApproval } = makeStore(); let id = ""; diff --git a/src/bob2-driver.ts b/src/bob2-driver.ts index 7a08b15..23df100 100644 --- a/src/bob2-driver.ts +++ b/src/bob2-driver.ts @@ -297,15 +297,21 @@ export class InProcessDriver implements BobDriver { // Wedge probe (2.0.2): a persisted approval older than approvalWedgeMs means Bob is frozen on a // prompt auto-approve didn't cover (an unverifiable command, or the deliberately un-approved `ask`) // — abort with it named instead of burning the dispatch timeout. Real settle wins first, so a - // finished turn with a stale approval row behind it still reports its true outcome. + // finished turn with a stale approval row behind it still reports its true outcome. A row with no + // created_at (schema drift — the DDL says NOT NULL) is skipped: a false abort of a healthy turn is + // worse than falling back to the timeout. let wedge: string | null = null; + const liveStore = store; // narrowed for the closure — TS can't see the null guard through capture + const quietMs = this.quietMs; // one source for both settle paths (the option and the closure) const { settled, row, maxGapMs } = await awaitTurnSettled(store, id, { pollMs: this.pollMs, - quietMs: this.quietMs, + quietMs, timeoutMs: opts.timeoutMs ?? 300_000, isSettled: (r) => { - if (turnSettled(r, this.quietMs)) return true; - const p = store!.pendingApprovals(id).find((a) => Date.now() - (a.created_at ?? 0) >= this.approvalWedgeMs); + if (turnSettled(r, quietMs)) return true; + const p = liveStore + .pendingApprovals(id) + .find((a) => a.created_at != null && Date.now() - a.created_at >= this.approvalWedgeMs); if (!p) return false; wedge = describePendingApproval(p.payload_json) ?? "unknown tool request"; return true; diff --git a/src/bob2-taskstore.test.ts b/src/bob2-taskstore.test.ts index 345ef7f..57a0a7c 100644 --- a/src/bob2-taskstore.test.ts +++ b/src/bob2-taskstore.test.ts @@ -456,10 +456,11 @@ test("pendingApprovals returns [] on a pre-2.0.2 store (no table) — probed, ne }); test("pendingApprovals reads a task's rows oldest-first, scoped to that task", () => { - const { db, store } = makeStore(); + const { db } = makeStore(); db.exec( "CREATE TABLE task_pending_approvals (task_id TEXT NOT NULL, request_id TEXT NOT NULL, payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (task_id, request_id))", ); + const store = new Bob2TaskStore(db); // built over the complete 2.0.2 schema, like a real 2.0.2 open const ins = db.prepare("INSERT INTO task_pending_approvals VALUES (?, ?, ?, ?)"); ins.run("t1", "r2", '{"permission":"execute"}', 200); ins.run("t1", "r1", '{"permission":"ask"}', 100); diff --git a/src/bob2-taskstore.ts b/src/bob2-taskstore.ts index c0fa96c..0c812a0 100644 --- a/src/bob2-taskstore.ts +++ b/src/bob2-taskstore.ts @@ -137,7 +137,8 @@ export function firstMessageMatches(firstMessage: string | null | undefined, con } /** A row of 2.0.2's `task_pending_approvals`: a tool request auto-approve did NOT cover, persisted while - * the task sits frozen waiting for the user. Absent as a table on 2.0.0/2.0.1 stores. */ + * the task sits frozen waiting for the user. Absent as a table on 2.0.0/2.0.1 stores. created_at is + * NOT NULL in the DDL but kept wide (Bob owns the column); the wedge probe skips a null rather than abort. */ export interface Bob2PendingApproval { request_id: string; payload_json: string; From e385093db3cb4f96167c5d4d9573b534a38e1288 Mon Sep 17 00:00:00 2001 From: Joshua Gilbert <54961107+Joshua-Gilbert@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:40:02 -0700 Subject: [PATCH 6/6] ci(release): publish the npm package from the same v* tag as the VSIX The npm side of a release was manual (2.1.0/2.2.0 were published by hand), so the package could lag the tagged extension + GitHub release. The Release workflow now carries a publish-npm job: same v* trigger, its own tag-vs-package.json drift gate, idempotent when the version is already on the registry, and a clear failure when the NPM_TOKEN secret is missing. Manual (workflow_dispatch) runs npm publish --dry-run instead of touching the registry. --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++++++-- CHANGELOG.md | 3 ++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8a29cc..e86106c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: Release -# Build the Bob Tasks VS Code extension into a .vsix and publish it as a GitHub -# release asset. Triggered by pushing a tag like `v1.0.0`, or manually for a dry run -# (manual runs only upload the artifact; they don't touch a release). +# One `v*` tag ships the whole release: the extension .vsix onto a GitHub release AND +# the npm package (@pounceai/bob-control) — same trigger, so the two can't drift apart. +# Manual runs are dry runs: they upload the .vsix artifact and `npm publish --dry-run`, +# touching neither the release nor the registry. on: push: tags: ["v*"] @@ -75,3 +76,55 @@ jobs: gh release create "$GITHUB_REF_NAME" extension/*.vsix \ --title "$GITHUB_REF_NAME" --generate-notes fi + + publish-npm: + # Publishing is registry I/O; `prepublishOnly` (build + shebang gate) supplies the + # artifacts, so the cheap ubuntu runner is fine here too. Independent of + # package-extension: a VSIX hiccup must not strand the npm side, and vice versa. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: "22.x" + cache: npm + # Writes the .npmrc that reads NODE_AUTH_TOKEN — publish fails auth without it. + registry-url: "https://registry.npmjs.org" + + - name: Verify tag matches package version + if: startsWith(github.ref, 'refs/tags/') + # Same drift gate as the extension job, for the ROOT manifest npm stamps. + run: | + pkg="v$(node -p "require('./package.json').version")" + if [ "$GITHUB_REF_NAME" != "$pkg" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $pkg — bump the manifest or retag." + exit 1 + fi + + - name: Install deps + run: npm ci + + - name: Publish to npm + if: startsWith(github.ref, 'refs/tags/') + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # A version already on the registry means a re-run after a successful publish — + # skip idempotently (npm versions are immutable; republish would only error). + # A missing token fails HERE with the fix, not as an opaque 401 from npm. + run: | + ver="$(node -p "require('./package.json').version")" + if [ -n "$(npm view "@pounceai/bob-control@$ver" version 2>/dev/null)" ]; then + echo "@pounceai/bob-control@$ver is already on the registry — skipping." + exit 0 + fi + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::error::NPM_TOKEN secret is not set — add an npm automation token in repo Settings → Secrets and re-run." + exit 1 + fi + npm publish + + - name: Dry-run publish (manual run) + if: github.event_name == 'workflow_dispatch' + # Exercises prepublishOnly + the files allowlist against the registry, writes nothing. + run: npm publish --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 072b494..1af1842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ pending-approval persistence below. approval older than `approvalWedgeMs` (default 5s) aborts the dispatch immediately with the tool named — e.g. `execute_command (execute)` — instead of burning the dispatch timeout (default 5 min). A finished turn still reports its true outcome past a stale approval row; a pre-2.0.2 store (no table) is a no-op. +- **npm publish rides the release tag.** The same `v*` tag that ships the .vsix + GitHub release now also + publishes `@pounceai/bob-control` (tag↔manifest drift gated on both manifests, idempotent on re-runs; + needs the `NPM_TOKEN` repo secret). A manual workflow run does `npm publish --dry-run` instead. ## [2.2.0] — 2026-07-09 — worker webhook + drainer health signal