Throwaway probe for the factory loop: can a mod wake the main model with a fixed line and get a typed answer back?

Step 1 of the go-ahead on 2026-10-12, and the first open risk in SPEC 8.4. A throwaway mod (hooks/register.ts), loaded in the spike-reconcile-mod workspace in the orchestrators' exact launch mode (claude --remote-control ... --dangerously-skip-permissions --resume ..., Claude Code 2.1.296). The raw log is evidence/log.jsonl.
| Question | Answer | Evidence |
|---|---|---|
Does $.prompt.submit start a model turn? | Yes. The turn started 20 to 70 ms after the call (the session was idle) | every submitted line |
| What does the model see? | The text, framed: "The wake-probe plugin sent a message: ..." plus an engine note that a plugin started the turn. It shows in the transcript, so on the phone too | the spike terminal |
Text answer (ANSWER w2 ok) | Works, three times out of three: 2 s, 12 output tokens, 370 to 630 tokens of context | turn lines for w2 |
Typed answer through a tool the mod registers (mcp__wake-probe__answer) | Works when the mod is stable: the call arrived 3.7 s after the wake, 750 tokens of context | 22:54 answer-tool line |
| Same, right after a load | Failed twice. Once right after the workspace restart, once when a file copy reloaded the mod as the turn started; the model said the tool was missing. A reload re-registers the tool, and a turn that starts in between does not get it | 22:43 and 22:46 turn lines |
Hidden context for the probe's own prompt, through its own prompt.submit hook | Not possible. A plugin's own hooks do not see its own $.prompt.submit; the hook ran only for a prompt a person typed | the one prompt-hook line (origin composer) |
| Do other plugins' mods and timers survive? | Yes: the spike mod's timer kept its one-minute beat through /reload-plugins and came back after the restart | spike log |
Found on the way:
CLAUDE_CODE_PLUGIN_DIRS is read when the session starts. /reload-plugins does not pick up a new folder, and a folder added later is not watched. Adding one needs a restart, and coder restart spike-reconcile-mod brought the same conversation back in 20 seconds.plugin.json. Deploys should touch only the files that changed (a git pull does).ANSWER <id> <decision> and optional key: value lines) is the primary channel. It survives reloads and costs least. The tool stays an accepted alternative.session.start and, as a fallback, from the first other hook that fires (session.append fires every minute with the title), so it never depends on one event.hooks/register.ts 166 lines1// Throwaway probe for the factory loop (docs/factory-loop/SPEC.md 8.4): in the orchestrators'
2// launch mode, can a mod wake the main model with one fixed line, hand it hidden context, and
3// get a typed answer back through a tool it registers? Runs only on request
4// (touch REQUEST), and writes one JSON line per observation to LOG:
5//
6// A tool answer JUDGE line + hidden context word; the model answers through
7// mcp__wake-probe__answer with the word as its reason
8// B text answer JUDGE line asking for a bare "ANSWER w2 ok" line and no tools
9//
10// For each: did a turn start and end, did the answer arrive and match, what did the main
11// conversation keep (session.append rows), and what did it cost (context tokens, usage).
12// It also tries the decision log's append path: one `sh -c 'cat >> file'` per line.
13import type { EngineInterface, Register } from 'claude-code'
14
15const DIR = '/persist/claude/loop/wake-probe'
16const LOG = `${DIR}/log.jsonl`
17const REQUEST = `${DIR}/run.request`
18const WORD = 'heron'
19const POLL_MS = 10_000
20const B_AFTER_MS = 60_000
21
22type Phase = { id: string; startedAt: number; contextBefore: number | null; rows: Record<string, unknown>[] }
23
24let current: Phase | undefined
25let busy = false
26
27async function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
28 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), check, ...fields }) + '\n'
29 try {
30 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', LOG], { stdin: line, timeoutMs: 10_000 })
31 } catch {
32 // a lost line is the probe's own problem, never the session's
33 }
34}
35
36// listed: the tools the model can call now whose name names the probe.
37const listed = async ($: EngineInterface) =>
38 (await $.tool.list()).filter(t => t.name.includes('wake')).map(t => ({ name: t.name, mcp: t.mcp }))
39
40const contextTokens = async ($: EngineInterface) => (await $.session.usage()).context.tokens ?? null
41
42async function start($: EngineInterface, id: string, text: string) {
43 current = { id, startedAt: await $.clock.now(), contextBefore: await contextTokens($), rows: [] }
44 await log($, 'submit', { id, text, contextBefore: current.contextBefore, listed: await listed($) })
45 const result = await $.prompt.submit({ text })
46 await log($, 'submitted', { id, entered: 'drop' in result ? `dropped: ${String(result.drop)}` : (result.text ?? '').slice(0, 200) })
47}
48
49async function poll($: EngineInterface) {
50 if (busy || current !== undefined || !(await $.fs.exists(REQUEST))) return
51 busy = true
52 try {
53 await $.process.run(['rm', '-f', REQUEST])
54 await start(
55 $,
56 'w1',
57 'JUDGE wake-test id=w1 key=probe-a (a test of the factory loop judgment wake: call the ' +
58 'mcp__wake-probe__answer tool once with id "w1", decision "ok" and, as reason, the word ' +
59 'the hidden context names; use no other tool and end your turn)',
60 )
61 } finally {
62 busy = false
63 }
64}
65
66// ensure: start once per module load, from whichever hook fires first. session.start does
67// not fire for a plugin a /reload-plugins adds to a running session, so every hook calls it.
68let started: Promise<void> | undefined
69function ensure($: EngineInterface) {
70 started ??= init($).catch(() => {
71 started = undefined
72 })
73 return started
74}
75
76async function init($: EngineInterface) {
77 const { tool } = await $.tool.register({
78 name: 'answer',
79 description: 'Answer a JUDGE line from the factory loop: the line id, a decision, and a reason.',
80 inputSchema: {
81 type: 'object',
82 properties: {
83 id: { type: 'string' },
84 decision: { type: 'string' },
85 reason: { type: 'string' },
86 },
87 required: ['id', 'decision'],
88 },
89 isDeferred: false,
90 })
91 await log($, 'loaded', { version: (await $.session.version()).version, tool, listed: await listed($) })
92 $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
93}
94
95export const register: Register = on => {
96 on('session.start', async ($, e, next) => {
97 const result = await next(e)
98 await ensure($)
99 return result
100 })
101
102 // The hidden context rides beside the probe's own prompt only.
103 on('prompt.submit', async ($, e, next) => {
104 await log($, 'prompt-hook', { origin: e.origin, phase: current?.id ?? null, context: e.context?.length ?? 0 })
105 if (e.origin.kind !== 'plugin' || current?.id !== 'w1') return next(e)
106 const context = [...(e.context ?? []), `wake-probe hidden context: the word is "${WORD}".`]
107 const result = await next({ ...e, context })
108 await log($, 'prompt-hook-result', { context: 'context' in result ? (result.context?.length ?? 0) : 'dropped' })
109 return result
110 }).catch(($, e, next) => next(e))
111
112 on('tool.call', { tool: 'mcp__wake-probe__answer' }, async ($, e) => {
113 const input = e as unknown as { id?: string; decision?: string; reason?: string }
114 await log($, 'answer-tool', {
115 id: input.id,
116 decision: input.decision,
117 reason: input.reason,
118 wordMatched: typeof input.reason === 'string' && input.reason.toLowerCase().includes(WORD),
119 })
120 return { result: `recorded ${input.id ?? '?'}` }
121 }).catch(() => ({ result: 'recorded' }))
122
123 on('session.append', async ($, e, next) => {
124 const result = await next(e)
125 void ensure($)
126 if (current !== undefined && e.agentId === undefined) {
127 current.rows.push({
128 door: e.door,
129 origin: e.origin.kind,
130 type: e.message.type,
131 name: e.message.name,
132 chars: JSON.stringify(e.message.content ?? '').length,
133 })
134 }
135 return result
136 }).catch(($, e, next) => next(e))
137
138 on('turn.complete', async ($, e, next) => {
139 const result = await next(e)
140 if (e.agentId !== undefined || current === undefined) return result
141 const phase = current
142 const answer = e.answer
143 await log($, 'turn', {
144 id: phase.id,
145 reason: e.reason,
146 durationMs: e.durationMs,
147 sinceSubmitMs: (await $.clock.now()) - phase.startedAt,
148 answer: answer.slice(0, 400),
149 textAnswer: answer.match(/^ANSWER (\S+) (\S+)/m)?.slice(1) ?? null,
150 usage: e.usage ?? null,
151 contextBefore: phase.contextBefore,
152 contextAfter: await contextTokens($),
153 rows: phase.rows,
154 })
155 current = undefined
156 if (phase.id === 'w1') {
157 $.clock.after(B_AFTER_MS, () =>
158 void start($, 'w2', 'JUDGE wake-test id=w2 key=probe-b (reply with exactly one line, ANSWER w2 ok, and use no tools)').catch(
159 () => undefined,
160 ),
161 )
162 }
163 return result
164 })
165}
166