The workspace probe's spawner: spawns one agent from a timer, as the factory loop does

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 91 lines1// The workspace probe's spawner (docs/factory-loop/v2/SPEC.md 10.9): it stands in for the factory
2// loop. Like the loop, it spawns its agent from a $.clock timer that its session.start scheduled,
3// so no hook frame of this plugin is open when the spawn is raised. README.md beside this folder
4// says how to run it and what each case shows. PROBE_RUN (an absolute folder) and PROBE_CASE pick
5// the case:
6// control - the agent runs `sleep 150` with no timeout argument and nothing set
7// envset-start - the same, after this module's session.start called $.env.set (the loop's shape)
8// envset-timer - the same, after a timer callback (no frame open) called $.env.set
9// gate - the agent starts in <run>/slot through the helper plugin's agent.spawn hook,
10// writes done.txt, tries a write outside the slot, and finishes; the helper's
11// classic.SubagentStop runs a 75 s gate with $.process.run; once it answered,
12// a message resumes it, as the loop's repair message does, to show the resumed
13// agent keeps its slot and its guard
14// Every observation is one JSON line in <run>/log.jsonl.
15import type { EngineInterface, Register } from 'claude-code'
16
17const SLEEP =
18 'Run exactly this one Bash command, as given: `date -u +%T; sleep 150; date -u +%T`. Do not pass a timeout parameter and do not set run_in_background. Run no other command. When it returns, reply with its exact output and one line saying whether it ran to completion in the foreground or was moved to the background.'
19
20const gateAsk = (slot: string) => `factory-slot: ${slot}
21Steps, each its own tool call:
221. Bash: pwd
232. Write tool: file_path "done.txt", content "hello"
243. Write tool: file_path "../escape.txt", content "escaped" (it may be refused: report what you were told)
25Then reply with one line per step and finish. If you are told a check failed when you finish, do what it says, then finish again.`
26
27const cases = new Map<string, string>()
28const resumed = new Set<string>()
29
30async function cfg($: EngineInterface) {
31 return { run: (await $.env.get('PROBE_RUN')) || '/tmp/workspace-probe', probe: (await $.env.get('PROBE_CASE')) || 'control' }
32}
33
34async function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
35 const { run } = await cfg($)
36 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), plugin: 'spawner', check, ...fields }) + '\n'
37 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${run}/log.jsonl`], { stdin: line, timeoutMs: 10_000 }).catch(() => undefined)
38}
39
40async function setTimeouts($: EngineInterface, where: string) {
41 await $.env.set('BASH_DEFAULT_TIMEOUT_MS', '600000')
42 await $.env.set('BASH_MAX_TIMEOUT_MS', '600000')
43 await log($, 'env.set', { where, BASH_DEFAULT_TIMEOUT_MS: (await $.env.get('BASH_DEFAULT_TIMEOUT_MS')) ?? null })
44}
45
46// spawn: the agent, from a timer. A headless session that is not built yet refuses a spawn (seen
47// once at 4 s on 2.1.300), so a refusal is tried again 2 s later, up to 10 times.
48async function spawn($: EngineInterface, tries = 1) {
49 const { run, probe } = await cfg($)
50 try {
51 const prompt = probe === 'gate' ? gateAsk(`${run}/slot`) : SLEEP
52 const r = await $.agent.spawn({ subagentType: 'workspace-probe-spawner:worker', description: `workspace probe ${probe}`, prompt })
53 if (r.agentId !== undefined) cases.set(r.agentId, probe)
54 await log($, 'spawned', { probe, tries, agentId: r.agentId ?? null, deny: r.deny ?? null })
55 } catch (err) {
56 await log($, 'spawned', { probe, tries, error: String(err) })
57 if (tries < 10) $.clock.after(2_000, () => void spawn($, tries + 1))
58 }
59}
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 const r = await next(e)
64 const { run, probe } = await cfg($)
65 await log($, 'session.start', { run, probe, version: (await $.session.version()).version })
66 if (probe === 'envset-start') await setTimeouts($, 'session.start')
67 // the loop's case: every timer below fires after this hook returned, outside any frame of ours
68 if (probe === 'envset-timer') $.clock.after(1_000, () => void setTimeouts($, 'timer').catch(err => log($, 'env.set', { error: String(err) })))
69 $.clock.after(4_000, () => void spawn($))
70 return r
71 })
72
73 on('turn.complete', async ($, e, next) => {
74 const r = await next(e)
75 if (e.agentId === undefined || !cases.has(e.agentId)) return r
76 const probe = cases.get(e.agentId)
77 await log($, 'answered', { probe, agentId: e.agentId, answer: e.answer.slice(0, 1500) })
78 // the loop's repair message resumes an agent with $.session.send from its turn.complete
79 // (register.ts repairTo): does the resumed agent keep its slot, and its guard?
80 if (probe === 'gate' && !resumed.has(e.agentId)) {
81 resumed.add(e.agentId)
82 const to = e.agentId
83 void $.session
84 .send({ to: { agentId: to }, text: 'One more step: run the Bash command `pwd`, then try the Write tool with file_path "../escape2.txt" and content "x", and reply with what each told you.' })
85 .then(sent => log($, 'resumed', { agentId: to, sent }))
86 .catch(err => log($, 'resumed', { agentId: to, error: String(err) }))
87 }
88 return r
89 }).catch(($, e, next) => next(e))
90}
91