The workspace probe's helper: a second plugin that starts the agent in its slot, guards its writes and gates its finish

Run autonomous coding agents on any repo, from issue to merged pull request, with one shared, versioned setup. It is for people who want to hand work to Claude Code (or codex) from a phone or a browser and come back to a reviewed PR, without keeping a shell open on any machine.
docker run, on Kubernetes or as a Coder workspace. In an orchestrator container, tmux holds one Claude session and a supervisor restarts it when it exits. dev-system is not a tmux multiplexer running many terminals on one machine; you scale out by running more containers.ready. The orchestrator session watching the repo delegates design and implementation to sub-agents, which work in git worktrees and open PRs through the no-mistakes pipeline.Three layers, each pinned independently:
callum-flow plugin in plugins/callum-flow/: the skills and hooks that turn issues into PRs. Beside it, the opt-in factory-hud mod shows usage and factory counts on every client, the phone included.images/base/: the tools, headless Claude with Remote Control, logins and persistent state.templates/ synced into each repo by callum-dev, so a repo pins a version and pulls updates.flowchart LR
app["Claude app<br/>(Remote Control)"] --> orch
gh["GitHub issue<br/>labelled ready"] --> orch
subgraph c1["Container: orchestrator (one per repo)"]
orch["Orchestrator session<br/>(one supervised Claude)"]
orch --> w1["Sub-agent<br/>worktree A"]
orch --> w2["Sub-agent<br/>worktree B"]
end
subgraph c2["Container: hands-on workspace"]
ws["Remote Control server<br/>(own worktree per new session)"]
end
app --> ws
w1 --> pr["Pull requests<br/>(no-mistakes pipeline)"]
w2 --> pr
ws --> pr
Containers are independent and sit side by side. See docs/architecture.md for the layers, versioning and the runtime map.
Dispatch from a phone
Label an issue ready
ready label.docker run, Kubernetes and Coder (images/base/).images/base/)./persist volumes (runtime storage).issue-orchestrator, implement-issue, /update-dev and /callum-flow:onboard (plugin in plugins/callum-flow/).callum-dev init, update and check (templates/).images/base/).New or existing repo: open an agent in the folder and tell it to follow docs/onboarding.md ("make this a dev-system repo"), or, with the callum-flow plugin installed, run /callum-flow:onboard.
By hand:
npm i -D github:cbundy/dev-system#semver:0.x
npx callum-dev init # then fill every <REPLACE> and commit
| Page | Read it for |
|---|---|
docs/onboarding.md | Making a repo a dev-system repo (written for agents) |
docs/architecture.md | Layers, versioning, how updates reach repos, the runtime map for debugging |
docs/metrics.md | Factory metrics queries and the no-mistakes PostgreSQL mirror |
images/base/README.md | The base image: persistence, dev-init, dev-doctor, Remote Control, logins, agentsview |
coder/dev-system/README.md | The Coder templates: pushing them, parameters, OTLP |
docs/developing-on-coder.md | Developing dev-system itself from a Coder workspace: logins, and what only CI can verify |
templates/README.md | Each template file: what is synced and what the repo owns |
RELEASING.md | Cutting a release of each layer |
hooks/register.ts 77 lines1// The workspace probe's helper (docs/factory-loop/v2/SPEC.md 10.9): a plugin other than the
2// spawner, in the companion plugin's role. Its agent.spawn hook starts an agent whose prompt opens
3// with `factory-slot: <path>` in that folder and maps the agent's id to it; its tool.call hook logs
4// the agent's calls and refuses a Write outside the slot; its classic.SubagentStop hook runs a gate
5// of about 75 s in the slot with $.process.run and blocks the finish while it fails. In the
6// timeout cases it only logs, so the Bash call's own `timeout` shows the agent passed none.
7import type { EngineInterface, Register } from 'claude-code'
8
9// fails until done.txt holds the line `fixed`; about 75 s either way
10const GATE = 'sleep 75; if grep -qx fixed done.txt; then echo gate passed; else echo "done.txt must hold exactly the line: fixed. Write it with the Write tool." >&2; exit 2; fi'
11
12const slotOf = new Map<string, string>()
13
14async function cfg($: EngineInterface) {
15 return { run: (await $.env.get('PROBE_RUN')) || '/tmp/workspace-probe' }
16}
17
18async function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
19 const { run } = await cfg($)
20 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), plugin: 'helper', check, ...fields }) + '\n'
21 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${run}/log.jsonl`], { stdin: line, timeoutMs: 10_000 }).catch(() => undefined)
22}
23
24function norm(p: string): string {
25 const out: string[] = []
26 for (const part of p.split('/')) {
27 if (part === '' || part === '.') continue
28 if (part === '..') out.pop()
29 else out.push(part)
30 }
31 return '/' + out.join('/')
32}
33
34export const register: Register = on => {
35 on('agent.spawn', async ($, e, next) => {
36 const m = /^factory-slot: (\/\S+)$/m.exec(e.prompt)
37 const r = await next(m === null ? e : { ...e, cwd: m[1] })
38 if (m !== null && r.agentId !== undefined) slotOf.set(r.agentId, m[1])
39 await log($, 'agent.spawn', { origin: (next as unknown as { origin?: unknown }).origin ?? null, resume: e.resume?.agentId ?? null, cwdIn: e.cwd ?? null, slot: m?.[1] ?? null, agentId: r.agentId ?? null, deny: r.deny ?? null })
40 return r
41 }).catch(($, e, next) => next(e))
42
43 on('tool.call', async ($, e, next) => {
44 if (e.agentId === undefined) return next(e)
45 const input = e as unknown as Record<string, unknown>
46 const slot = slotOf.get(e.agentId) ?? null
47 await log($, 'tool.call', { agentId: e.agentId, slot, tool: e.tool, command: input.command ?? null, timeout: input.timeout ?? null, file_path: input.file_path ?? null })
48 if (slot !== null && e.tool === 'Write' && typeof input.file_path === 'string') {
49 const p = norm(input.file_path.startsWith('/') ? input.file_path : `${slot}/${input.file_path}`)
50 if (p !== slot && !p.startsWith(`${slot}/`)) {
51 await log($, 'guard-deny', { agentId: e.agentId, file_path: input.file_path })
52 return { deny: `workspace-probe: ${input.file_path} is outside your slot ${slot}` }
53 }
54 }
55 const t0 = await $.clock.now()
56 const r = await next(e)
57 await log($, 'tool.result', { agentId: e.agentId, tool: e.tool, ms: (await $.clock.now()) - t0, isError: r.isError ?? false, text: String(r.text ?? r.deny ?? '').slice(0, 600) })
58 return r
59 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'workspace-probe: its guard failed' }))
60
61 on('classic.SubagentStop', async ($, e, next) => {
62 const slot = slotOf.get(e.agent_id) ?? null
63 if (slot === null) return next(e)
64 const t0 = await $.clock.now()
65 await log($, 'gate-start', { agentId: e.agent_id, stop_hook_active: e.stop_hook_active, cwd: e.cwd })
66 try {
67 const ran = await $.process.run(['sh', '-c', GATE], { cwd: slot, timeoutMs: 600_000 })
68 const secs = Math.round(((await $.clock.now()) - t0) / 1000)
69 await log($, 'gate-end', { agentId: e.agent_id, exitCode: ran.exitCode, secs, stderr: ran.stderr.trim() })
70 if (ran.exitCode !== 0) return { block: `GATE failed (exit ${ran.exitCode}) in ${slot}: ${ran.stderr.trim()}` }
71 } catch (err) {
72 await log($, 'gate-error', { agentId: e.agent_id, error: String(err), secs: Math.round(((await $.clock.now()) - t0) / 1000) })
73 }
74 return next(e)
75 }).catch(($, e, next) => next(e))
76}
77