@pounceai/bob-control
v2.2.0
Published
Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board.
Maintainers
Readme
Bob Control
Bob Control turns an AI coding agent from a one-prompt-at-a-time chat window into an unattended queue worker. Fill a board and each task gets dispatched, auto-approved inside risk guardrails, answered when the agent asks a question, verified (a command check or an LLM judge), retried on transient failures, and written back — in dependency and priority order, with no one watching. Gating and an acceptance judge keep it honest, and IBM Bob and Claude Code can share one board so each does the work it's best at.
Concretely: an MCP server + CLI + auto-dispatch worker over a SQLite task board.
The worker pulls each task in dependency and priority order and dispatches it to
IBM Bob (IBM's AI coding agent for IBM i), then
watches it run to completion. On Bob 2.0 that dispatch is in-process (the companion
extension calls Bob's exported startTask API); on Bob 1.x it's live over the node-ipc
pipe — auto-detected, one build. Bob is the MCP client; this is the server it connects to — and
Claude Code speaks the same MCP, so it can work the same board as foreman (provision / route /
triage) or worker.
One build, both Bobs — auto-detected. Still on 1.x and want the last pipe-only release? Grab v1.1.0 from Releases. Full matrix: CHANGELOG → Compatibility.
Where it fits
This is the runtime execution + orchestration layer: it actually drives a live agent,
keeps it unattended, and verifies the output. That's a different job from planning /
governance CLIs (e.g. MissionForge), which
deterministically decompose and scope work but don't run an agent. The two compose — plan
upstream, then queue the pieces here with depends_on ordering and let the worker drain them.
What this layer owns that a planning CLI doesn't: live agent control (dispatch, auto-approve,
verify), LLM-judged acceptance, a multi-agent shared board, and command/risk gating.
Setup
npm install # not --ignore-scripts: esbuild's postinstall fetches its binary
npm run build # tsc -> dist/, then bundles claude-plugin/server/server.mjs
npm run smoke # optional self-testOr skip the clone and run the published package — the MCP server (bob-control) and CLI
(bob-tasks) come straight from npm. Point an MCP client at it instead of an absolute dist/ path:
{
"mcpServers": {
"bob-tasks": { "type": "stdio", "command": "npx", "args": ["-y", "@pounceai/bob-control"] }
}
}Under Claude Code the board resolves automatically (it sets CLAUDE_PROJECT_DIR); any other MCP
client should add "env": { "BOB_TASKS_DB": "…" } pointing at the board the worker drains — a bare
npx server with no board env writes to a throwaway path inside the npx cache that nothing can share.
Windows + nvm-for-windows:
npxcan fail to resolve the bin ('bob-control' is not recognized). Install globally and run it with plainnodeinstead:npm i -g @pounceai/bob-control, then setcommandtonodeandargsto["<npm root -g>/@pounceai/bob-control/dist/server.js"].
The unattended worker and the Claude Code plugin still want the repo (see below).
Needs Node 22.5+ (uses the built-in node:sqlite, so there's no native build step).
Board path resolution: BOB_TASKS_DB (explicit) › BOB_TASKS_PORTABLE=1 (a shared
~/.bob-tasks/tasks.db) › BOB_TASKS_WORKTREE_SHARED=1 (every linked git worktree resolves the
main worktree's data/tasks.db; a no-op for non-worktree dirs) › CLAUDE_PROJECT_DIR/data/tasks.db
(Claude Code sets CLAUDE_PROJECT_DIR in every MCP server it spawns — plugin and a plain
terminal/project .mcp.json — so both resolve the same project board) › repo-local module-relative
data/tasks.db.
The plugin points each project at its own board (see
Boards are per project). On first open the store also writes a
.gitignore beside itself, so the board never lands as untracked state in a consuming repo.
All processes must agree on one board. The plugin MCP, a terminal-configured MCP, the worker, and Bob each resolve
tasks.dbindependently; if they diverge,await_taskpolls a board nothing writes to and never settles. The server logs the resolved board path on startup — confirm every process prints the same one. The worker is launched outside Claude Code (so it has noCLAUDE_PROJECT_DIR); point it with an explicitBOB_TASKS_DBmatching the project board.
Connecting Bob
Bob reads project MCP servers from .bob/mcp.json. Copy the template and point
it at your clone:
cp .bob/mcp.json.example .bob/mcp.json
# edit the four /absolute/path/to/bob-control pathsThe template pins BOB_TASKS_DB to the repo's own data/tasks.db (boards are per
project — see below); for one shared queue across every repo, replace that env entry
with "BOB_TASKS_PORTABLE": "1" on both Bob and the plugin.
Bob hot-reloads .bob/mcp.json on save and lists bob-tasks under the MCP
Servers panel's Project tab. To make it available in every workspace, put the
same mcpServers block in the global file instead:
%APPDATA%\IBM Bob\User\globalStorage\ibm.bob-code\settings\mcp_settings.jsonBob 2.0 keeps its settings under
~/.bob/rather than the%APPDATA%\IBM Bobpath above; the simplest version-independent route is the in-app MCP Servers panel (+ Add Server), which writes to whichever location your Bob build uses. ThemcpServersblock is identical either way — this wiring is independent of the dispatch transport.
Bob Companion (Claude Code plugin)
Because the connector is an MCP server and Claude Code is an MCP client, Claude Code can work the same board Bob drains — as the foreman (provision / route / triage) and as a worker (claim and execute tasks itself). It ships as a self-contained Claude Code plugin in claude-plugin/ so you can use it from any repo, not just this one.
The plugin bundles the connector into a single server/server.mjs (esbuild; no native
deps thanks to node:sqlite), so install is all that's required — no checkout needed at
runtime. Build it, add the local marketplace, install:
npm install && npm run build # build also bundles claude-plugin/server/server.mjs
# in Claude Code, from anywhere:
# /plugin marketplace add /absolute/path/to/bob-control
# /plugin install bob-companion@bob-control| Surface | What it does |
| ------- | ------------ |
| /bob-new <desc> | Turn a rough ask into one well-formed task (title, criteria, priority, tags, routed mode) |
| /bob-board [tag] | The board grouped by status, in pull order |
| /bob-next [tag] | What Bob pulls next and the mode it routes to (read-only; never claims) |
| /bob-route <id\|text> | Predict the dispatch mode for a task or a hypothetical |
| /bob-triage [focus] | Review the board, propose fixes, apply the safe ones on confirm |
| /bob-review-diff [range] | Queue a read-only Bob (review-mode) code review of the diff you just made; findings land on the board |
| /bob-work [tag] [--max N] | Worker: claim pending tasks Claude can do, execute them, submit results |
| /bob-statusline | Install (or --remove) a live one-line board summary in your Claude Code status line |
| bob-foreman subagent | Split a large request into several correctly-routed, ordered tasks |
| Skills (model-invoked) | Ask for IBM Bob by name to dispatch its specialty modes: bob-review (review), bob-plan (plan), bob-refactor (refactor), bob-security (devsecops) |
The dispatch surfaces — bob-review / bob-plan / bob-refactor / bob-security and
/bob-review-diff — block on Bob via await_task and present the result in the same turn, so
a review / plan / fix runs end-to-end hands-off while a worker is draining the board
(npm run worker); with no worker draining they fall back to "queued as #id".
Boards are per project
The plugin's MCP server points at the open project's own board —
${CLAUDE_PROJECT_DIR}/data/tasks.db — so every repo has its own queue. For Bob to share a
project's queue, its .bob/mcp.json must set the same BOB_TASKS_DB for that project;
tools/init-project-board.mjs <dir> scaffolds a correct
per-project .bob/mcp.json (pointing at the shared connector's dist/server.js, with
BOB_TASKS_DB/cwd set, and creates data/). /bob-work claims tasks as claude and
leaves IBM-i/RPG work for Bob, so the two never double-work a task.
Prefer one queue across every repo instead? Set BOB_TASKS_PORTABLE=1 on both the plugin
and Bob to share a fixed ~/.bob-tasks/tasks.db. Either way the server logs the resolved
board path on startup, so you can confirm both tools agree.
From a plain terminal (no plugin)
A plain claude terminal session gets the tools from a project/user .mcp.json but not the
plugin's skills (/bob-work, bob-review, …) that script the dispatch loop — so without
guidance it may fall back to polling the board by hand instead of blocking. Two things make it work
like the plugin:
Point it at the project board. In a hand-written
.mcp.json,${CLAUDE_PROJECT_DIR}is only substituted when you give it a default —${CLAUDE_PROJECT_DIR:-.}(bare${CLAUDE_PROJECT_DIR}is a plugin-only direct substitution). The server also readsCLAUDE_PROJECT_DIRfrom its own environment, so even an unsetBOB_TASKS_DBresolves the same project board the plugin uses:// <project>/.mcp.json { "mcpServers": { "bob-tasks": { "type": "stdio", "command": "node", "args": ["/absolute/path/to/bob-control/dist/server.js"], "env": { "BOB_TASKS_DB": "${CLAUDE_PROJECT_DIR:-.}/data/tasks.db" } } } }Drive it with
await_task, don't poll. Aftercreate_task, callawait_taskto block until the task settles; if it returnsneeds_input,answer_task_questionthenawait_taskagain. Don't loop onlist_tasks/board_statusto watch a task —await_taskblocks server-side and needs a worker draining the board (npm run workeragainst the sameBOB_TASKS_DB).
Tools
| Tool | Purpose |
| ---- | ------- |
| create_task | Add a task (staged:true = created non-pullable, for bulk curation) |
| list_tasks | List tasks, filter by status/tag |
| get_task | One task plus its notes and any open pending_question |
| get_next_task | Highest-priority pending task; optionally claim it (null while disarmed) |
| claim_task | Mark in_progress and assign (only pending, only while armed) |
| update_task_status | Set status (pending / in_progress / blocked / analysis_done / done / cancelled; staged & needs_input are set by their own tools) |
| add_task_note | Append a progress note |
| submit_result | Complete a task: read-only run → analysis_done; implementation reaches done only with evidence |
| set_task_mode | Set or clear a task's mode slug |
| set_task_dependencies | Set/clear the task IDs this one waits on (all must be done/analysis_done); rejects cycles |
| record_artifact | Record a file/commit/test a worker produced (delete-safety + done-evidence) |
| delete_task | Delete a task; warns/refuses if it recorded artifacts unless force, with optional cleanup |
| revert_task | Roll the working tree back to the task's pre-task checkpoint (undo what it changed) |
| board_report | Markdown standup/audit of the board, grouped by status |
| board_status | Dispatch state: armed?, worker-active heuristic, counts by status |
| arm_board / disarm_board | Resume / pause all dispatch (curation gate) |
| release_tasks | Move staged tasks → pending (release after curation) |
| ask_question | Raise a question for a human; parks the task needs_input (never guess) |
| answer_task_question | Answer a worker's question (by question_id); resumes the worker |
| await_answer | The worker's blocking wait for an answer (poll of the shared board) |
| await_task | Block until a task settles (done / analysis_done / blocked / cancelled / needs_input), then return it with its result — dispatch-and-wait |
A typical Bob loop: get_next_task {claim:true} then add_task_note while working,
then submit_result. If it needs a value it can't determine, ask_question → await_answer
rather than guessing.
From the foreman side, a dispatcher that queues work can create_task → await_task to block
until Bob finishes and act on the result in the same turn — no board polling. await_task is a
chunked poll of the shared board (each call stays under the MCP timeout; call again if it returns
{status:'waiting'}), so the settling write Bob's worker makes "hooks" you straight back into your
turn. It resolves on every settled state — including needs_input, where it hands back Bob's open
question so you can answer_task_question and await_task again.
CLI
The CLI shares the same store:
node dist/cli.js create "Modernize INVRPT report" --priority high --tags rpg
node dist/cli.js create "Bulk task" --staged # non-pullable until released
node dist/cli.js list --status pending --tag rpg
node dist/cli.js show 1
node dist/cli.js claim 1 --assignee bob
node dist/cli.js status 1 blocked # set status (rejects staged/needs_input)
node dist/cli.js mode 1 refactor # set/clear the mode slug
node dist/cli.js deps 3 1,2 # task 3 waits on 1 and 2 (empty clears)
node dist/cli.js note 1 "Waiting on test data"
node dist/cli.js result 1 "Done; 3 procedures extracted"
node dist/cli.js next # next pending task + its routed mode
node dist/cli.js delete 1 [--force] [--cleanup] # refuses if it wrote files; cleanup removes created ones
node dist/cli.js revert 1 [--force] # roll the tree back to task 1's checkpoint while one exists (--force if HEAD moved)
node dist/cli.js stats
node dist/cli.js report # markdown standup/audit of the board
node dist/cli.js report --status blocked --out report.md
# curation gate (pause dispatch while you triage, then release)
node dist/cli.js disarm "triaging" # no worker pulls until armed
node dist/cli.js release --tag batch # staged -> pending (all, or by ids/tag)
node dist/cli.js arm # resume dispatch
node dist/cli.js board # armed? + counts by status
# answer a worker's question (see "Asking a human" below)
node dist/cli.js questions # open questions awaiting an answer
node dist/cli.js answer 5 <question_id> pool size is 20report groups tasks by status in pull order, each with age, idle time, the
latest note, and a ⚠ stalled flag for in_progress work idle over 30 min — handy
for a standup or spotting wedged tasks. It also prints an audit summary tallying
unattended automation activity from task notes: classifier approvals/denials,
answerer answers/escalations, and human actions (with an estimated classifier
cost). Same output is available to agents via the board_report MCP tool.
Modes
Each task carries a mode (or routes to one); on dispatch the connector sends it to Bob along with a matching risk level and auto-approve profile (see src/modes.ts). Eight built-in modes:
| Mode | Risk | What it's for |
| ---- | ---- | ------------- |
| ask | safe | Read-only — explain / research / document. No writes or commands. |
| plan | safe | Read-only planning/design — produces a plan, no code changes. |
| review | safe | Read-only code review — returns structured findings (see below). |
| code | standard | Normal edit + build + run. The default. |
| refactor | standard | Behavior-preserving restructuring/cleanup. |
| devsecops | standard | Security-focused remediation — finds and fixes vulnerabilities (write-capable; the LLM judge verifies the fix). |
| orchestrator | standard | Coordinate a multi-step epic with sub-tasks. |
| advanced | elevated | code plus MCP + browser power. |
Leave a task's mode blank and the keyword router picks one from the title, description, and
tags. The router only auto-routes to ask, advanced, orchestrator, or code; the
specialty modes (plan / review / refactor / devsecops) are reached by setting the mode
explicitly, a tag naming the mode, or the plugin's model-invoked skills.
node dist/cli.js create "Explain the IPC envelope" # routes to ask
node dist/cli.js create "Fix bug in db.ts" # routes to code
node dist/cli.js create "Add export" --mode refactor # explicit
node dist/cli.js route 1 # preview the routed modePrecedence: explicit mode, then a tag naming a mode, then the keyword router,
then code.
Worker
The worker drains the board one task at a time: pull the next eligible task
(highest priority whose dependencies are all done), route it, dispatch to Bob
in the sidebar, wait for the result, write it back, mark it done (verification and
retry below are opt-in).
npm run worker # drain, then idle-poll
node dist/worker.js --once # one task then exit
node dist/worker.js --tag rpg
node dist/worker.js --dry-runFlags: --once --tag <t> --dry-run --pipe --poll --timeout --assignee
--new-tab --max-risk <safe|standard|elevated> --retry <N> --detect-plan-stop
--no-notify --no-defer --answer-followups --escalate-all --review-plans
--allow-commands <prefix,prefix> --deny-commands <prefix,prefix> --no-command-gate
--allow-all-commands --no-checkpoint --no-idle-watchdog --idle-timeout <ms>
--no-budget --budget-cap <tokens> --max-turns <n> --webhook <url> --webhook-secret <s>
(plus the verify-and-continue flags below).
Needs Bob running with IPC enabled (see below).
Aborted or timed-out tasks are parked as blocked; with --retry <N> the worker first
re-dispatches a transient failure (timeout/abort) up to N times before parking it.
--detect-plan-stop catches a "completion" that wrote no code (Bob just presented a plan)
and continues it instead of marking it done.
The worker is resilient across Bob restarts: if the IPC pipe drops it reconnects on the next
dispatch (instead of wedging), and on startup it re-queues any task a previous run left
in_progress when it died mid-dispatch, so nothing is stranded.
Each mode has a risk level, and the worker only dispatches tasks at or below
--max-risk (default standard); higher-risk ones stay pending for manual
dispatch. On finish the worker pops a tray toast (--no-notify to silence; the
system sound and terminal bell are off by default).
--webhook <url> POSTs the notable transitions — a task finishing, blocking, needing input, or
retrying, and the worker itself stopping or erroring — to a URL as application/json. One payload
serves three consumers: it carries text (a Slack incoming webhook renders it), content (a Discord
webhook renders it), and the structured { event, seq, data, worker, ts } for a generic receiver
(seq monotonic within worker.run, a per-process id, so a restart reads as a new run, not dropped
deliveries; concurrent POSTs can be reordered). Delivery is
best-effort: a POST never blocks or crashes the drain, bursts past an in-flight cap to a slow endpoint
are dropped, and the worker flushes pending POSTs before it exits so the final event still lands. The
URL is validated at startup (http/https only) and only its host is logged, since a Slack/Discord webhook
URL carries its secret in the path. The payload transmits the repo path (cwd), task titles, and Bob's
question text to that URL — point it only at an endpoint you trust; --webhook-secret <s> HMAC-signs the
body (X-Bob-Signature: sha256=…) so a generic receiver can verify authenticity.
Run it standing (hands-off loop). Creating a task doesn't start Bob — a worker has to pull it.
Keep one always draining and the plugin's dispatch skills become end-to-end hands-off: they
create_task, then await_task hooks back with Bob's result in the same turn (no manual dispatch,
no "check back later"). launch-worker.cmd starts one; autostart it at logon so it's always up.
No admin: drop a shortcut to it in your Startup folder (Win+R → shell:startup). With an
elevated terminal you can use a Scheduled Task instead (Task Scheduler needs admin):
schtasks /create /tn "BobWorker" /sc onlogon /tr "\"<repo>\launch-worker.cmd\"" /f # remove: schtasks /delete /tn "BobWorker" /fTask dependencies
A task can declare depends_on — other task IDs that must all be done before it
becomes eligible. The worker's pull queue skips a task while any dependency is unmet
(surfaced as blocked on dependencies), so you can queue an ordered pipeline and let one
drain run it end to end. Dependencies are validated when set: a missing ID is rejected, and
an edge that would form a cycle is refused (DFS check in src/db.ts).
node dist/cli.js create "Write tests for INVRPT" # -> #5
node dist/cli.js create "Refactor INVRPT" --depends-on 5 # won't dispatch until #5 is done
node dist/cli.js deps 6 5 # set/clear deps on an existing task (empty clears)Over MCP, pass create_task {depends_on:[…]} or call the set_task_dependencies tool.
Unattended execution
Each dispatch sends the mode's auto-approve profile — the autoApprovalEnabled
master switch, per-category toggles, and a curated SAFE_COMMANDS allowlist — so
Bob runs without stalling on approval prompts. Read-only modes (ask/plan/review)
take no writes; the write-capable modes (code/refactor/devsecops/orchestrator/
advanced) add writes; all of them auto-run allowlisted commands (build/test/vcs) and
prompt for anything else. This per-dispatch profile is enough for the worker on
its own; to apply the same auto-approve to your interactive Bob session, fully quit
Bob and launch via launch-bob-ipc.cmd (it runs
set-bob-autoapprove.mjs, which writes the same allowlist to
Bob's global state).
For the gray zone (a command not on the allowlist), the worker resolves the approval prompt
non-interactively by default — a headless dispatch never waits on a human. A deterministic
permission gate (command-policy.ts) evaluates the command: an allowlisted one
(safe local git subcommands, pytest / uv run pytest / python -m pytest, python, scoped
__pycache__ cleanup) is approved; a denied or unrecognised one (git push, network installs,
curl/wget/sudo, rm -rf outside the repo) is rejected, recorded as a needs_input question
(the exact command + cwd + task), and the dispatch is ended promptly so a blocked command can't
burn the wall-clock. Tune it with --allow-commands / --deny-commands (comma-separated), or:
node dist/worker.js --allow-commands "make build,go test" # extend the auto-run allowlist
node dist/worker.js --no-command-gate # disable the gate (idle watchdog stays the backstop)
node dist/worker.js --allow-all-commands # SANDBOX ONLY: auto-run every command, gate offFor an unrecognised command the deterministic gate default-denies; to instead have Claude judge
the gray zone, add --command-classifier:
node dist/worker.js --command-classifier # cli backend: reuses your Claude login, no key
node dist/worker.js --command-classifier --classifier-backend api # one Anthropic call (needs ANTHROPIC_API_KEY)The classifier presses approve/reject over IPC, which needs a one-time patch that exposes Bob's
buttons — node tools/patch-bob-buttons.mjs, then restart Bob. Fail-safe: only an explicit "approve"
runs a command; any error or timeout falls back to the deterministic default-deny. Extra flags:
--classifier-backend <cli|api> --classifier-model --classifier-cli.
The other thing that stalls an unattended task is Bob asking a question mid-task
(e.g. "which approach should I take?"). With --answer-followups, the worker asks
Claude to answer — preferring one of Bob's offered options — and sends the reply back
over IPC (a native SendMessage, so no button patch). It applies in any mode and uses
the same backend as the classifier. Fail-safe: when the answerer is unsure or the
question is consequential (deletes, scope changes), it escalates to you (a desktop
toast + a note on the task) instead of guessing.
node dist/worker.js --answer-followups # Claude answers Bob's questions; escalates when unsure
node dist/worker.js --answer-followups --escalate-all # escalate ALL questions to you (including plan approvals)
node dist/worker.js --answer-followups --review-plans # escalate plan/design questions, auto-answer mechanical onesWith --escalate-all, every followup question is escalated to you for review instead of
being auto-answered — ensuring you see and approve plans/designs before Bob proceeds. Off
by default (Claude answers confident questions); only applies when --answer-followups is on.
With --review-plans, plan/design-approval questions ("should I proceed with this refactor?",
"which approach should I take?") are escalated to you for review, while mechanical clarifications
("which file?", "flag name -x or -y?") are auto-answered. This is a middle ground between
--escalate-all (blocks on every question) and the default (auto-answers everything confident).
When both --review-plans and --escalate-all are set, --review-plans takes precedence.
Verify-and-continue with LLM judge
When --verify-and-continue is on, the worker runs an acceptance check after Bob completes
a task and loops back to Bob to fix issues until it passes or --max-continues is reached.
This catches broken builds/tests without human intervention.
By default, with no --verify-command set, the loop blind-passes (no reliable heuristic).
Add --verify-judge to use an LLM judge that reviews Bob's completion against the task
criteria and actual code changes (git diff). The judge uses the same backend as the command
classifier (reuses your Claude login or ANTHROPIC_API_KEY). The diff is scoped to this
task's changes — captured against a pre-dispatch baseline so pre-existing edits in a dirty
tree aren't attributed to the task, and new (untracked) files the task creates are included.
node dist/worker.js --verify-and-continue --verify-judge # judge is sole verifier
node dist/worker.js --verify-and-continue --verify-command "npm test" --verify-judge # command first, then judge
node dist/worker.js --verify-and-continue --verify-command "npm test" # command only, no judgeWhen both --verify-command and --verify-judge are set, the command runs first and must
pass; the judge then provides an additional gate (both must pass). When only --verify-judge
is set, the judge is the sole acceptance signal. The judge fails safe: any LLM error or timeout
is treated as a pass (logged as a task note) so infrastructure failures never block tasks.
Resilience guards: checkpoint-before-death, idle watchdog, token budget
The worker hardens each unattended dispatch against the ways it can die without finishing. These are on by default (in a git repo); tune or disable them with the flags noted.
Checkpoint-before-death → branch. Before each task the worker captures a pre-task git
snapshot (pinned, gc-safe). On any terminal failure — an idle / blocked-on-ask trip, a token
budget abort, a no-result timeout/abort (after retries), a verify/judge give-up, or a worker error
— it preserves the dispatch's partial work to a dedicated bob/task-<id> branch and restores the
working tree to its pre-task state, so a dying run never leaves uncommitted WIP on main. Recover
the stranded work with git checkout bob/task-<id>; the failure note records the branch and root
cause. --no-checkpoint disables it; no-op outside a git repo, or when the task committed its
work (HEAD moved — see Verified below).
The same machinery powers the manual bob revert <id> / revert_task rollback, which restores a
task's pre-task checkpoint while one still exists. The checkpoint is consumed on completion
(both on success and when failed work is preserved to a branch), so it never pins a commit per task
forever — which means revert is a no-op once a task has terminated; recover a failed task's work
from its bob/task-<id> branch instead.
The manual rollback is destructive, so it's safe by construction (src/checkpoint.ts):
- Repo-bound — the checkpoint records the repo it came from; a revert run in any other working tree is refused, never applied to the wrong repo.
- Pinned — the snapshot is held by a real ref (
refs/bob/checkpoint/<id>), sogit gccan't reclaim it before you revert. - Verified — if the snapshot is missing, or HEAD moved since capture (a commit landed),
the revert refuses rather than silently half-restoring or orphaning a commit (
--force/force:trueoverrides the HEAD-moved check). - Recoverable — the pre-revert state is pinned to a
refs/bob/recovery/<sha>ref first, so discarded work is never truly lost (the command prints the ref). - HEAD-preserving — tracked files are restored with
git read-tree(HEAD/branch untouched); only files the task created are removed (emptied dirs pruned). Pre-task edits and pre-existing untracked files are left intact.
No-op outside a git repo. Gitignored files a task creates aren't auto-removed (they're not the
tracked snapshot's concern). Deleting a task also drops its checkpoint ref; refs/bob/recovery/<sha>
refs from past reverts are kept as a safety net and can be pruned by hand once you're sure you don't
need them.
Idle / blocked-on-ask watchdog. A dispatch that makes no progress for --idle-timeout (180s
default) — or wedges on a prompt the headless worker can't answer (e.g. a command-permission ask) —
ends as idle well before the wall clock, after a short --blocked-ask-grace (10s). The pending ask
is surfaced to the board as a needs_input question rather than swallowed. --no-idle-watchdog
disables it (wall-clock --timeout only).
Token / turn budget. Each task carries an estimated token scope (set at creation — see
right-sizing); the worker enforces estimate × (1 + --budget-headroom%) (floored), or a flat
--budget-cap when there's no estimate, as a hard ceiling that aborts a runaway loop as budget.
--max-turns adds an api-request cap; --no-budget disables both. (Best-effort: it reads Bob's
api-request token usage, and falls open — never trips — if that usage isn't reported.)
Right-sizing at creation. create_task estimates a task's single-dispatch scope from its
description length, the files it names, and its mode (scope.ts) and stamps the
estimated_tokens the budget ceiling above is derived from. An oversized task is tagged too-big,
and an oversized implementation task with no explicit mode is routed to orchestrator — which
decomposes it into subtasks — rather than dispatched doomed to a mid-work timeout.
Review-mode findings
When a review task finishes, the worker captures Bob's findings to the board. Bob's
native review writes structured issues (severity, location, category, often a suggested
fixed_diff); under headless dispatch it's tool-restricted and returns the review as
completion text instead, so the worker parses that text back into structured findings
(src/review-findings.ts). Either way the findings land as the task
result plus a bob-review note — the board, not Bob's webview panel, is the source of
truth. Queue one with /bob-review-diff (or the bob-review skill / --mode review).
Templates
create --template <name> applies a preset mode, priority, tags, and
description scaffold:
node dist/cli.js templates
node dist/cli.js create "INVRPT report" --template bug-fixBuilt-ins: bug-fix, feature, research, code-review, doc, refactor.
VS Code / Bob extension
extension/ packages the auto-dispatch loop as an IDE extension ("Bob Tasks") for IBM Bob (and
stock VS Code). It claims and dispatches queued tasks from inside the editor, with a status-bar
item, native completion/failure toasts, a bobTasks.maxRisk gate, and defer-while-chatting so it
never aborts a live Bob conversation.
It auto-detects the Bob it's running in and picks the transport:
- Bob 2.0 (current) — runs the dispatch loop in-process and calls Bob's exported
startTaskAPI directly; no child process, no pipe. Completion is read from Bob's task store (~/.bob/db/bob.db). Headless auto-approve is written to Bob's global settings, gated bybobTasks.autoApproveGlobal(default on; a one-time notice fires on the first write — see extension/README.md). - Bob 1.x (legacy) — spawns
dist/worker.js, which dispatches over thenode-ipcpipe; needs Bob launched withROO_CODE_IPC_SOCKET_PATH(launch-bob-ipc.cmd).
A URI handler (ibm-bob://local.bob-tasks/start) lets a process outside the editor — e.g. Claude
Code in WSL — start or stop the loop without a Command Palette click. It's independent of the MCP
server and CLI; on Bob 1.x you can skip it and run npm run worker instead. Build/install steps and
the full bobTasks.* settings reference live in extension/README.md.
Board safety gates
Guardrails for running unattended at scale — so a bulk-create can't race a live worker, and "done" means something:
- Staging / arming. Create tasks
staged(non-pullable) andrelease_tasksthem once you've reviewed the set, ordisarm_boardto pause all dispatch while you curate, thenarm_board. A worker never pulls a staged task or pulls while disarmed (enforced in the board layer, so both the worker and/bob-workobey it). - Done-integrity. A read-only run (
ask/plan/review) terminates asanalysis_done, notdone. An implementation task reachesdoneonly with execution evidence (files changed / commit / test); without it the worker parks itanalysis_donerather than show green for unbuilt work.submit_resulttakes anevidencefield; the worker derives it from the task's git diff. - Delete-safety. Workers record the files/commits they produce as task artifacts, so
delete_taskwarns and refuses when a task already wrote files (unlessforce);cleanupremoves only files the task created — never source it merely edited.
Asking a human (board round-trip)
When a worker needs a value it can't determine, it asks on the board and waits — it does
not guess. ask_question parks the task needs_input and writes the question (with
optional multiple-choice options) where any board client can see it: get_task returns a
pending_question, and board_report lists an "❓ Awaiting answer" group with how long
it's been waiting. The worker then blocks on await_answer (a poll of the shared board).
Anyone answers with one call — answer_task_question(task_id, question_id, answer) over MCP, or
bob answer <id> <question_id> <text> from a terminal — and the waiting worker resumes. Answers
are matched by a unique question_id, so a stale answer can't apply to a new question. Fail-safe:
a question unanswered past its deadline times out and the task parks blocked (a board-activity
sweep fires this even if the asking worker died) — never a fabricated answer, never a silent
done. The bob-work skill follows this path instead of inventing a value.
Driving Bob over IPC (Bob 1.x — legacy)
Bob 1.x only. IBM Bob 2.0 removed the
node-ipcserver, so there's no pipe to drive — on 2.0 the companion extension drives Bob in-process (see the extension), and the outside-process commands here (--cancel,--list-pipes, a directStartNewTask) have no 2.0 analog: 2.0 exposes no external control channel.
Bob starts a node-ipc server when launched with ROO_CODE_IPC_SOCKET_PATH
set. bob-control.mjs connects to it, sends StartNewTask,
and streams back Bob's events:
node bob-control.mjs "Refactor src/db.ts"
node bob-control.mjs --mode ask "Explain this codebase"
node bob-control.mjs --cancel <taskId>
node bob-control.mjs --list-pipesTo enable it, fully quit Bob, then relaunch from the Start menu or launch-bob-ipc.cmd (which also enables auto-approve for unattended runs — see above).
Worktrees: run N in parallel
Keep main, feat-a, … checked out at once and let Bob work in each at the same time on one
shared board, without two windows racing the same checkout. A task is pinned to a worktree with a
worktree:<name> tag (routing); each worktree gets its own Bob window draining only its slice.
The shared-board machinery is identical on both Bob versions: BOB_TASKS_WORKTREE_SHARED=1
resolves the main worktree's data/tasks.db from any linked worktree, and the worktree:<name>
tag pin — the one exact-match filter shared by the worker's pickEligible, nextTask, and
get_next_task — routes each task end-to-end. What differs between Bob versions is only how each
window's dispatch is isolated.
Bob 2.0 (in-process — recommended)
2.0 makes this simpler: with no IPC pipe, each window's in-process loop dispatches into its own workspace — the per-instance pipe plumbing (the 1.x section below) is gone. Per worktree:
1. Add the worktree and build once in the main checkout (each window's loop loads its dist/):
git worktree add ..\bob-control-feat-a feat-a
npm run build2. Open it in its own Bob 2.0 window with a distinct --user-data-dir — the one piece carried
over from the 1.x launcher, since Electron's single-instance lock otherwise refuses a 2nd window:
bobide --user-data-dir "%LOCALAPPDATA%\bob-wt\feat-a" ..\bob-control-feat-a3. Point that window's loop at its slice via the worktree's .vscode/settings.json (workspace
scope); with autoStart the in-process loop comes up draining only its tag on the shared board:
{ "bobTasks.tag": "worktree:feat-a", "bobTasks.worktreeShared": true, "bobTasks.autoStart": true }4. Pin tasks to a worktree and each dispatches only into that window:
node dist/cli.js create "Add export to feat-a" --tags worktree:feat-aNo pipe, no launch-bob.cmd, no print-pipe-name.mjs, no per-window worker.js. The
isolation rules below carry over. Not carried over: the worker
lease (board_status.worker_leases) — the in-process loop is one-per-window by construction, so
there's no cross-worker lease to coordinate, and board_status's worker fields won't reflect the 2.0
loops (they read the 1.x heartbeat). Opening the same worktree in two windows is the one
unguarded misconfig; don't.
Bob 1.x (IPC pipe — legacy)
The model: one Bob window and one worker per worktree, sharing the main worktree's
data/tasks.db. A task is pinned to a worktree (routing); a worktree is leased by one live
worker at a time (exclusivity). Both are required because a Bob 1.x window owns one IPC pipe and edits
one folder, so a task only reaches worktree X if a Bob+worker already bound to X pulls it.
1. Add the worktrees (each on its own branch) and build once in the main checkout — the
launcher and worker run its dist/:
git worktree add ..\bob-control-feat-a feat-a
git worktree add ..\bob-control-feat-b feat-b
npm run build2. Launch one Bob per worktree. launch-bob.cmd binds a Bob to one workspace
on a distinct pipe + --user-data-dir (so two Electron instances coexist), seeds auto-approve
into %LOCALAPPDATA%\bob-instances\<slug>, and scaffolds bobTasks.pipe into that worktree's
.vscode/settings.json. The pipe is derived from the workspace path by
tools/print-pipe-name.mjs — the single source of truth, shared by the
launcher and the worker — so concurrent windows never collide on one global pipe:
.\launch-bob.cmd C:\dev\bob-control # main window
.\launch-bob.cmd C:\dev\bob-control-feat-a # feat-a window3. Start one worker per worktree — cwd = the worktree (so checkpoints / diffs / evidence run against its files and the lease keys on its path), the shared board on, pinned to its tag, and pointed at its Bob's pipe:
cd C:\dev\bob-control-feat-a # worker cwd = the worktree
$env:BOB_TASKS_WORKTREE_SHARED = "1" # resolve the MAIN worktree's board
$env:ROO_CODE_IPC_SOCKET_PATH = (node C:\dev\bob-control\tools\print-pipe-name.mjs C:\dev\bob-control-feat-a)
node C:\dev\bob-control\dist\worker.js --tag worktree:feat-aBOB_TASKS_WORKTREE_SHARED=1 makes defaultDbPath() resolve the main worktree's board from any
linked worktree (a no-op for non-worktree dirs — see Setup). The lease refuses a 2nd
worker on the same checkout and reclaims a dead one after the heartbeat window; board_status
lists worker_leases so a foreman sees who owns what. (In-editor: set bobTasks.tag +
bobTasks.worktreeShared; bobTasks.pipe is scaffolded by step 2.)
4. Pin tasks to a worktree and each dispatches only into that window:
node dist/cli.js create "Add export to feat-a" --tags worktree:feat-a
node dist/cli.js create "Fix feat-b regression" --tags worktree:feat-bRules that keep it isolated
The pin is a convention the tag filter enforces at discovery, not at claim (both Bob versions), so:
- Tag every worker and every task; never run an untagged worker beside them — an untagged
puller (
workerwith no--tag,bob next,/bob-work) sees every pinned task. - Keep a task's
depends_onin its own worktree — aworktree:feat-atask waiting on aworktree:feat-btask deadlocks: feat-a's worker never pulls the prerequisite. - One
worktree:tag per task — two would make both workers eligible. - Workers can share the default assignee
bob: startup reclaim is tag-scoped, so a restart re-queues only that worker's ownworktree:<name>slice.
Verify: create one task per worktree, confirm each lands in its own window and edits its own
checkout, that board_status shows one lease per worktree, and that feat-a's worker never pulls
feat-b's task.
Task model
id, title, description, status, priority (low/medium/high/urgent), tags[],
mode (or null to auto-route), depends_on[] (task IDs that must be done/analysis_done
first), assignee, result, timestamps, plus per-task notes, artifacts
(files/commits/tests a worker produced), and questions (the ask/answer round-trip) tables.
Status lifecycle: staged (created non-pullable) → pending → in_progress →
needs_input (awaiting a human answer) → back to in_progress, or a terminal
analysis_done (read-only / no verified code) · done · blocked · cancelled.
Development
npm run build # tsc -> dist/ + the plugin bundle
npm test # node:test suite (board logic, gates, parsers, checkpoint, …)
npm run lint # ESLint 9 (flat config, typescript-eslint)
npm run format # Prettier --write (format:check to verify)CI (GitHub Actions, windows-latest, Node 22 + 24) runs lint → format-check → build →
test-with-coverage on every push/PR; a red check blocks merge. Before opening a PR, run
npm run lint && npm run format:check && npm test.
Layout
src/
types.ts task types and enums
db.ts SQLite store + repository (connection, tasks, deps, artifacts, checkpoints)
questions.ts board-native ask/answer/timeout round-trip (extracted from db.ts)
completion.ts done-integrity gate: completeTask + Evidence (extracted from db.ts)
await-task.ts await_task poll classification (settled / needs_input / unsettled) — testable seam
modes.ts mode slugs, router, per-mode risk + auto-approve profiles
templates.ts task templates
bob-ipc.ts async IPC client (BobClient; approve/reject/sendMessage)
bob-polls.ts verify-and-continue poll loop (re-dispatch until it passes)
llm.ts shared Claude transport (api / cli backends)
classify.ts command-safety classifier (gray-zone command asks) + hard deny-list
json-extract.ts balanced-brace JSON extraction shared by classify/answer/judge
command-policy.ts deterministic command allow/deny/escalate policy (allowlist source + gate resolver)
permission-gate.ts default-on non-interactive permission gate (allow->approve, deny->needs_input + end)
command-gate.ts gray-zone LLM approve/reject gate (worker -> IPC), the escalation path
answer.ts answerer for Bob's followup questions
followup-gate.ts answer-or-escalate gate for followup asks (worker -> IPC)
worker-answer.ts handle a human's answer to an escalated followup
judge.ts LLM acceptance judge + scoped git-diff capture (verify-and-continue)
verdict-cache.ts LRU cache for classifier verdicts (skip repeat calls for identical commands)
review-findings.ts (de)serialize review-mode findings (text <-> structured) for the board
retry-policy.ts retry/backoff for failed dispatches
watchdog.ts idle / blocked-on-ask dispatch watchdog (ends a wedged run early)
budget.ts per-dispatch token/turn budget tracker + ceiling
scope.ts task scope estimate for right-sizing at creation
checkpoint.ts per-task git checkpoint: capture, preserve-to-branch on death, revert
defer.ts pause dispatch while you're chatting with Bob
report.ts board -> markdown standup/audit (CLI + board_report tool)
notify.ts desktop toast and terminal bell
server.ts MCP server (stdio)
cli.ts CLI
worker.ts auto-dispatch loop
smoke.ts self-test
tools/
patch-bob-buttons.mjs expose Bob's approve/reject buttons + GetReviewFindings over IPC
measure-completion-latency.mjs read-only probe: Bob's completion -> board settling (await_task lag)
extension/ VS Code / Bob "Bob Tasks" extension — packages the worker (see extension/README.md)