SLOPSHOPPER

wake-probe

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

newguardprompttoolprocesstimer
★ 2v0.1.0no licenseupdated 2026-10-12cbundy/dev-system/docs/factory-loop/probes/wake-probe
A shopper browsing a rack in a slop shop
README

Wake probe: can a mod wake the model and get an 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.

Results

QuestionAnswerEvidence
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 toothe spike terminal
Text answer (ANSWER w2 ok)Works, three times out of three: 2 s, 12 output tokens, 370 to 630 tokens of contextturn 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 context22:54 answer-tool line
Same, right after a loadFailed 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 it22:43 and 22:46 turn lines
Hidden context for the probe's own prompt, through its own prompt.submit hookNot possible. A plugin's own hooks do not see its own $.prompt.submit; the hook ran only for a prompt a person typedthe 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 restartspike 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.
  • Copying a file over a loaded mod reloads it, and so does a changed plugin.json. Deploys should touch only the files that changed (a git pull does).
  • When the model cannot do what a wake asks, it explains at length (400 output tokens). The judge skill has to make short answers the rule.

What changes in the spec

  • Answers (SPEC 8.2): the text block (ANSWER <id> <decision> and optional key: value lines) is the primary channel. It survives reloads and costs least. The tool stays an accepted alternative.
  • Details (SPEC 8.1): a wake carries its details in the visible prompt text, after the first line, since a mod cannot attach hidden context to its own prompt. The owner sees them on the phone, which suits notices and judgments alike.
  • Starting (SPEC 2): the loop starts from 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.
Source 1 files
hooks/register.ts 166 lines
1// 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