Throwaway probe for the factory loop: can an agent a mod spawns from a timer call that mod's own tool?

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 65 lines1// Throwaway probe for the factory loop (docs/factory-loop/SPEC.md 13, #560, #561): can an agent
2// that a mod spawns with $.agent.spawn call that mod's own tool? The loop's passes run from its
3// timer, so its agents are spawned from a $.clock callback; on Claude Code 2.1.296 and 2.1.299
4// such an agent's call fails in milliseconds with "<plugin> registered the tool ... but no
5// tool.call hook answered this call" and the hook never runs, while the same spawn made inside a
6// command.run hook works. That is why the loop's agents answer through the checked answer script
7// (config answerChannel `script`, a temporary workaround) and not through the registered answer
8// tools (`tool`, the intended design).
9//
10// Run it on each Claude Code upgrade, in a throwaway session:
11//
12// claude --plugin-dir docs/factory-loop/probes/timer-spawn-probe
13// /timer-spawn-probe
14//
15// It spawns one agent inside the command's own hook (`hook`, the control) and one from a
16// $.clock.after callback (`timer`, the loop's case); each loads the probe's tool with ToolSearch,
17// calls it and replies with its result. Every observation is one JSON line in
18// ${CLAUDE_CONFIG_DIR:-~/.claude}/timer-spawn-probe/log.jsonl: `spawned` per case, `called` when
19// the tool hook runs (with the caller's agentId), and `answered` with each agent's reply.
20// answerChannel can go back to `tool` once the timer agent's reply is `pong` and its `called`
21// line is there. On 2.1.299 (2026-10-14) the timer agent replied with the engine's refusal above,
22// and no `called` line came.
23import type { EngineInterface, Register } from 'claude-code'
24
25const ASK = 'Use ToolSearch with query select:mcp__timer-spawn-probe__ping, then call mcp__timer-spawn-probe__ping with {} and reply with the exact tool result text only.'
26const cases = new Map<string, string>()
27
28async function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
29 const dir = `${(await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) || '~'}/.claude`}/timer-spawn-probe`
30 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), check, ...fields }) + '\n'
31 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${dir}/log.jsonl`], { stdin: line, timeoutMs: 10_000 }).catch(() => undefined)
32}
33
34async function spawn($: EngineInterface, via: 'hook' | 'timer') {
35 const r = await $.agent.spawn({ subagentType: 'timer-spawn-probe:pinger', description: `ping probe (${via})`, prompt: ASK })
36 if (r.agentId !== undefined) cases.set(r.agentId, via)
37 await log($, 'spawned', { via, agentId: r.agentId ?? null, deny: r.deny ?? null })
38}
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 const r = await next(e)
43 await $.tool.register({ name: 'ping', description: 'Answers pong. Call it when asked.', inputSchema: { type: 'object', properties: {} } })
44 await $.command.register({ name: 'timer-spawn-probe', description: 'spawn one agent from this hook and one from a timer; each calls the probe tool' })
45 return r
46 })
47
48 on('tool.call', { tool: 'mcp__timer-spawn-probe__ping' }, async ($, e) => {
49 await log($, 'called', { agentId: e.agentId ?? null, via: e.agentId === undefined ? null : (cases.get(e.agentId) ?? 'unknown') })
50 return { result: 'pong' }
51 }).catch(() => ({ deny: 'timer-spawn-probe: the hook failed' }))
52
53 on('command.run', { command: 'timer-spawn-probe' }, async $ => {
54 await spawn($, 'hook')
55 $.clock.after(500, () => void spawn($, 'timer').catch(err => log($, 'spawned', { via: 'timer', error: String(err) })))
56 return { text: 'timer-spawn-probe: one agent spawned from this hook, one from a timer; see log.jsonl' }
57 })
58
59 on('turn.complete', async ($, e, next) => {
60 const r = await next(e)
61 if (e.agentId !== undefined && cases.has(e.agentId)) await log($, 'answered', { via: cases.get(e.agentId), agentId: e.agentId, answer: e.answer.slice(0, 500) })
62 return r
63 }).catch(($, e, next) => next(e))
64}
65