Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
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.1] — 2026-08-14 — Bob 2.0.3: mode preflight

Verified against the 2.0.3 bundle and a live store: `startTask`, the tasks/messages/`task_pending_approvals`
schema (newest migration is still 2.0.2's `010_pending_approvals`), the active→running→active lifecycle, and
the `settings.json` auto-approve keys are all unchanged, so 2.0.3 needs no driver changes. The `costs` JSON
gained a `contextTokens` field, which the existing parser ignores. The fix below is a pre-existing gap 2.0.3
did not cause.

### Fixed

- **A mode the workspace can't load no longer hangs the dispatch.** `review`/`refactor`/`devsecops` are not
Bob built-ins — 2.0.3 ships `agent`/`plan`/`ask` — they come from the workspace's `.bob/custom_modes.yaml`
that `init-project-board.mjs` installs. In a project without that file the slug doesn't resolve, and Bob
reports it by doing nothing: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview
and returns, after `openTask` has already created the task row. The driver correlated a row that never ran,
so `updated_at` never passed `created_at`, the completion watch never settled, and the dispatch burned its
full wall clock to report a bare `timeout`. It now resolves the slug against the workspace's modes first,
names the file to add, and runs the turn in a fallback mode rather than stalling. The fallback preserves
the mode's safety profile: a read-only slug lands on `ask` — the only built-in with no `edit` group — not
on write-capable `agent`, so a review that lost its custom mode still cannot rewrite the code it was sent
to inspect.

## [2.3.0] — 2026-08-07 — Bob 2.0.2: trust preflight + approval-wedge fast-abort

Verified against the 2.0.2 bundle: every contract the driver relies on (startTask, tasks/messages schema,
Expand Down
2 changes: 1 addition & 1 deletion claude-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3.0",
"version": "2.3.1",
"author": {
"name": "Joshua Gilbert"
},
Expand Down
2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"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.3.0",
"version": "2.3.1",
"publisher": "local",
"license": "Apache-2.0",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion lhm.plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"identifier": "pounceai-bob-control",
"name": "Bob Control",
"version": "2.3.0",
"version": "2.3.1",
"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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pounceai/bob-control",
"version": "2.3.0",
"version": "2.3.1",
"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",
Expand Down
50 changes: 46 additions & 4 deletions src/bob2-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,11 @@ test("dispatch surfaces a post-start store-open fault (cold start, then the db w
test("toBob2Mode remaps removed 1.x built-ins to agent; passes through 2.0 built-ins and custom modes", () => {
// removed 1.x built-ins our router still emits → agent (Bob 2.0 throws "Mode not found" on these)
for (const m of ["code", "advanced", "orchestrator"]) assert.equal(toBob2Mode(m), "agent");
// 2.0 built-ins pass through
for (const m of ["agent", "ask", "plan", "review"]) assert.equal(toBob2Mode(m), m);
// CUSTOM modes pass through unchanged — Bob 2.0 loads custom_modes.yaml and resolves them
for (const m of ["refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m);
// 2.0 built-ins pass through (agent/plan/ask are Bob 2.0.3's DEFAULT_MODES)
for (const m of ["agent", "ask", "plan"]) assert.equal(toBob2Mode(m), m);
// CUSTOM modes pass through unchanged — incl. review, which is OURS (workspace .bob/custom_modes.yaml),
// not a Bob built-in. Whether the workspace actually loads them is the preflight's job, not this map's.
for (const m of ["review", "refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m);
// no mode → Bob's default coding mode
assert.equal(toBob2Mode(undefined), "agent");
assert.equal(toBob2Mode(null), "agent");
Expand Down Expand Up @@ -384,6 +385,47 @@ test("dispatch forwards the WorkspaceFolder OBJECT (not the string) and the tran
assert.equal(seenMode, "agent"); // board "code" → Bob 2.0 "agent"
});

test("a mode the workspace can't load warns and downgrades — the dispatch still runs, it does not fail", async () => {
const { store, seedRoot, bump } = makeStore();
let seenMode: unknown = "UNSET";
let id = "";
const warnings: string[] = [];
const host = makeHost({
folder: DIR, // no .bob/custom_modes.yaml under it, so `review` is not loaded
startTask: (o) => {
seenMode = o.mode;
id = seedRoot("running");
},
});
const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast });
setTimeout(() => bump(id, "active"), 15);
const res = await driver.dispatch({ text: "review the diff", mode: "review" });
assert.equal(res.status, "completed"); // the point: a missing mode must not fail the dispatch
assert.equal(seenMode, "ask"); // read-only stays read-only — never the write-capable agent
assert.equal(warnings.length, 1);
assert.match(warnings[0], /mode 'review' is not loaded/);
assert.match(warnings[0], /custom_modes\.yaml/);
});

test("a mode Bob can load dispatches as itself and prints nothing", async () => {
const { store, seedRoot, bump } = makeStore();
let seenMode: unknown = "UNSET";
let id = "";
const warnings: string[] = [];
const host = makeHost({
folder: DIR,
startTask: (o) => {
seenMode = o.mode;
id = seedRoot("running");
},
});
const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast });
setTimeout(() => bump(id, "active"), 15);
await driver.dispatch({ text: "explain this", mode: "ask" }); // a 2.0 built-in — always loadable
assert.equal(seenMode, "ask");
assert.deepEqual(warnings, []);
});

// ── defer-while-chatting: externalActivity (driver wiring over foreignActivity) ─────────────────────

test("externalActivity is true while a foreign chat is running in our workspace", async () => {
Expand Down
20 changes: 14 additions & 6 deletions src/bob2-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type Bob2TaskRow,
} from "./bob2-taskstore.js";
import { writeAutoApprove } from "./bob2-config.js";
import { resolveAvailableMode } from "./bob2-modes.js";
import { producesReviewFindings } from "./modes.js";
import { parseReviewFindings, type ReviewIssue } from "./review-findings.js";

Expand Down Expand Up @@ -79,6 +80,8 @@ export interface InProcessDriverOptions {
/** 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;
/** Where the mode-preflight notice goes. Default: `console.warn`. Injected by tests to assert on it. */
warn?: (message: string) => void;
}

/**
Expand Down Expand Up @@ -110,14 +113,16 @@ export function mapOutcome(
return { ...base, status: "timeout" };
}

// Removed 1.x built-ins our auto-router still emits; Bob 2.0 throws `Mode with id "<x>" not found` on them
// (built-ins are agent/ask/plan/review, coding = agent).
// Removed 1.x built-ins our auto-router still emits. Bob 2.0's built-ins are agent/plan/ask (DEFAULT_MODES),
// coding = agent, so these three have nothing to resolve to.
const BOB2_REMOVED_BUILTIN_MODES: Record<string, string> = { code: "agent", advanced: "agent", orchestrator: "agent" };

/**
* Board slug → a mode Bob 2.0 resolves. Only the removed 1.x built-ins are rewritten; the 2.0 built-ins
* and every custom mode pass through, since Bob 2.0 loads custom_modes.yaml (so review/refactor/devsecops
* dispatch as themselves; an unregistered slug then surfaces Bob's clean "Mode not found"). No mode → agent.
* Board slug → the slug we ask Bob for. Only the removed 1.x built-ins are rewritten; everything else passes
* through, including review/refactor/devsecops — those are OURS, defined in the workspace's
* .bob/custom_modes.yaml, not Bob built-ins. Whether the open workspace actually loads them is a separate
* question, answered by resolveAvailableMode at dispatch (an unloaded slug does NOT error — Bob just never
* runs the turn), so this map stays a pure rename. No mode → agent.
*/
export function toBob2Mode(mode: string | undefined | null): string {
if (!mode) return "agent";
Expand Down Expand Up @@ -268,12 +273,15 @@ export class InProcessDriver implements BobDriver {
return fail(`task store: ${(e as Error).message}`);
}
const snapshot = store ? store.snapshotRoots() : { ids: new Set<string>(), sinceMs: 0 };
// Mode preflight (see bob2-modes): an unloaded slug would hang the dispatch, so warn and downgrade.
const picked = resolveAvailableMode(toBob2Mode(opts.mode), dir);
if (picked.warning) (this.opts.warn ?? console.warn)(picked.warning);
try {
try {
// workspaceFolder = the WorkspaceFolder object; mode a slug Bob resolves (see Bob2StartTask / toBob2Mode).
await this.handle!.startTask({
content: opts.text,
mode: toBob2Mode(opts.mode),
mode: picked.mode,
workspaceFolder: this.host.workspaceFolderObject() ?? undefined,
});
} catch (e) {
Expand Down
Binary file added src/bob2-modes.test.ts
Binary file not shown.
70 changes: 70 additions & 0 deletions src/bob2-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { bob2HomeDir } from "./bob2-config.js";
import { isReadOnlyMode } from "./modes.js";

// Will Bob 2.0 resolve this mode slug in this workspace? Worth asking up front because an unresolvable slug
// does NOT error: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview and returns,
// but `openTask` already created the task row — so the driver correlates a row that never runs, updated_at
// never passes created_at, and the dispatch burns its full wall clock to report a bare 'timeout'.
// Verified against Bob 2.0.3, 2026-08-14.

/** Bob 2.0.3's DEFAULT_MODES — all that resolves without a custom_modes.yaml. */
export const BOB2_BUILTIN_MODES: readonly string[] = ["agent", "plan", "ask"];

/**
* Custom-mode slugs available in `workspaceDir`, read the way Bob reads them: the workspace's
* `.bob/custom_modes.yaml` (where init-project-board.mjs puts our review/refactor/devsecops) plus the global
* `~/.bob/settings/custom_modes.yaml`. Scraped, not parsed — only slugs matter and the repo carries no YAML
* dep (init-project-board.mjs makes the same call). Over-reporting is the safe direction: a slug we wrongly
* accept just restores the hang above, while one we wrongly miss would downgrade a working mode.
*/
export function customModeSlugs(workspaceDir: string | null): Set<string> {
const files = [
workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : null,
join(bob2HomeDir(), "settings", "custom_modes.yaml"),
];
const slugs = new Set<string>();
for (const f of files) {
if (!f || !existsSync(f)) continue;
let raw: string;
try {
raw = readFileSync(f, "utf8");
} catch {
continue; // unreadable config is not a dispatch failure — fall through to the built-ins
}
for (const m of raw.matchAll(/^\s*-?\s*slug:\s*["']?([A-Za-z0-9_-]+)["']?/gm)) slugs.add(m[1]);
}
return slugs;
}

/**
* Where an unresolvable mode lands. Keyed on the safety profile rather than always `agent`: `ask` is the only
* built-in with no `edit` group, so a read-only slug that lost its custom mode still can't rewrite the code it
* was sent to inspect. It has no `execute` either — a degraded review can't run `git diff`, which is weaker
* but weak in the safe direction.
*/
export function fallbackMode(mode: string): string {
return isReadOnlyMode(mode) ? "ask" : "agent";
}

export interface ModeAvailability {
/** The slug to dispatch — `mode` itself, or its fallback. */
mode: string;
/** Set only when a fallback was substituted. */
warning?: string;
}

/** Resolve `mode` for `workspaceDir`. Reports and downgrades, never rejects — a project missing its
* custom_modes.yaml still gets a usable turn. */
export function resolveAvailableMode(mode: string, workspaceDir: string | null): ModeAvailability {
if (BOB2_BUILTIN_MODES.includes(mode) || customModeSlugs(workspaceDir).has(mode)) return { mode };
const to = fallbackMode(mode);
const where = workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : "<workspace>/.bob/custom_modes.yaml";
return {
mode: to,
warning:
`bob2: mode '${mode}' is not loaded in this workspace — running as '${to}'. ` +
`Add it to ${where} (e.g. \`node tools/init-project-board.mjs <project>\`).`,
};
}