Throwaway spike: can a mod drive the orchestrator loop in a Remote Control session?

Results: FINDINGS.md, with the raw log in evidence/log.jsonl. Next step, as a brief for an autonomous agent: CONVERSION-BRIEF.md.
Moved here on 2026-10-16 from the spike/reconcile-mod branch, where it ran. Paths in these notes are as they were on that branch. The conclusions went into the loop spec and its decisions.
Answers one question before any real work: can a Claude Code mod (a hooks module plugin) drive the orchestrator loop inside a Remote Control session? It does nothing to any repo. It only logs what it observes and shows its state in the session title.
| Check | Passes when |
|---|---|
| 1 loaded | the log has a loaded line and the title reads spike · loaded ... |
| 2 tick | a tick line every minute, with no gaps while the session sits idle |
| 3 title | the title updates every minute ($.mcp.call, no permission prompt) |
| 4 handback | after touch /persist/claude/spike/handback-test.request, a handback-result line two minutes later with no mainRows and mainTurns: 0 (the agents reported only to the mod) |
| 5 restart | after dev-restart-self, a second loaded line and ticks resume |
It runs in the throwaway Coder workspace spike-reconcile-mod (repo dev-system-testbed), loaded through CLAUDE_CODE_PLUGIN_DIRS in that workspace's Claude settings, from a clone of this branch at /persist/claude/spike/dev-system.
Read the results:
coder ssh spike-reconcile-mod -- cat /persist/claude/spike/log.jsonlhooks/register.ts 168 lines1// Throwaway spike, never merged: can a mod drive the orchestrator loop in a
2// Remote Control session? Every check appends a JSON line to LOG, and the
3// session title shows the latest state so it can be followed from the phone.
4//
5// 1 loaded session.start fires: a "loaded" line (again after each restart)
6// 2 tick a "tick" line every minute, also while the session sits idle
7// 3 title $.mcp.call sets the session title, with no permission prompt
8// 4 handback on request (touch REQUEST), a built-in and a callum-flow agent
9// are spawned at once; two minutes later a "handback-result" line
10// says what reached the main conversation besides the mod's own
11// renames, and how many model turns ran
12// 5 restart after dev-restart-self: a new "loaded" line and ticks resume
13import type { EngineInterface, McpToolResult, Register } from 'claude-code'
14
15const LOG = '/persist/claude/spike/log.jsonl'
16const TICK_MS = 60_000
17const REQUEST = '/persist/claude/spike/handback-test.request'
18const TEST_AGENTS = ['Explore', 'callum-flow:explorer']
19const TEST_PROMPT = 'Reply with the single word pong. Do not use any tools.'
20const REPORT_AFTER_MS = 2 * 60_000
21
22type HandbackTest = {
23 startedAt: string
24 agents: Record<string, string>
25 answers: Record<string, unknown>[]
26 mainRows: Record<string, unknown>[]
27 mainTurns: number
28}
29
30let sessionId: string | undefined
31let ticks = 0
32let test: HandbackTest | undefined
33let writing: Promise<void> = Promise.resolve()
34
35const hhmm = () => new Date().toISOString().slice(11, 16)
36
37// Serialized, so lines logged from overlapping hooks are never lost.
38function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
39 const line = JSON.stringify({ at: new Date().toISOString(), check, ...fields }) + '\n'
40 writing = writing
41 .then(async () => {
42 const prev = (await $.fs.exists(LOG)) ? String(await $.fs.read(LOG)) : ''
43 await $.fs.write(LOG, prev + line)
44 })
45 .catch(() => undefined)
46 return writing
47}
48
49const textOf = (result: McpToolResult) =>
50 result.content.map(block => ('text' in block ? String(block.text) : '')).join('')
51
52const contextTokens = async ($: EngineInterface) => (await $.session.usage()).context.tokens
53
54// A session started from claude.ai has the claude-code-remote server; one the
55// CLI started (`claude --remote-control`, as orchestrators are) has none, so
56// fall back to the built-in /rename, run as if typed.
57let titleRoute: 'mcp' | 'rename' = 'mcp'
58
59async function setTitleByMcp($: EngineInterface, title: string) {
60 if (sessionId === undefined) {
61 const found = await $.mcp.call('claude-code-remote', 'get_session', {})
62 sessionId = JSON.parse(textOf(found)).ccr?.id
63 if (sessionId === undefined) throw new Error(`no session id: ${textOf(found).slice(0, 200)}`)
64 }
65 const set = await $.mcp.call('claude-code-remote', 'set_session_title', { session_id: sessionId, title })
66 if (set.isError) throw new Error(textOf(set).slice(0, 200))
67}
68
69async function setTitle($: EngineInterface, title: string) {
70 if (titleRoute === 'mcp') {
71 try {
72 await setTitleByMcp($, title)
73 return 'mcp'
74 } catch (err) {
75 void log($, 'title', { route: 'mcp', ok: false, error: String(err) })
76 titleRoute = 'rename'
77 }
78 }
79 try {
80 const result = await $.command.run({ command: 'rename', args: title })
81 void log($, 'title', { route: 'rename', ok: true, result })
82 return 'rename'
83 } catch (err) {
84 void log($, 'title', { route: 'rename', ok: false, error: String(err) })
85 return false
86 }
87}
88
89async function tick($: EngineInterface) {
90 ticks += 1
91 await maybeStartHandbackTest($)
92 const tokens = await contextTokens($)
93 const titled = await setTitle($, `spike · tick ${ticks} · ${hhmm()} UTC · ctx ${tokens ?? '?'}`)
94 void log($, 'tick', { n: ticks, contextTokens: tokens, titled })
95}
96
97// Only on request, so a reload never spawns agents by itself.
98async function maybeStartHandbackTest($: EngineInterface) {
99 if (test !== undefined || !(await $.fs.exists(REQUEST))) return
100 await $.process.run(['rm', '-f', REQUEST])
101 const run: HandbackTest = { startedAt: new Date().toISOString(), agents: {}, answers: [], mainRows: [], mainTurns: 0 }
102 test = run
103 const spawned = await Promise.all(
104 TEST_AGENTS.map(subagentType =>
105 $.agent.spawn({ subagentType, model: 'haiku', description: `handback test: ${subagentType}`, prompt: TEST_PROMPT }),
106 ),
107 )
108 spawned.forEach((result, i) => {
109 if (result.agentId !== undefined) run.agents[result.agentId] = TEST_AGENTS[i] ?? '?'
110 })
111 void log($, 'handback-spawned', { spawned, contextTokens: await contextTokens($) })
112 $.clock.after(REPORT_AFTER_MS, () => {
113 void (async () => {
114 await log($, 'handback-result', { ...run, contextTokens: await contextTokens($) })
115 test = undefined
116 })()
117 })
118}
119
120export const register: Register = on => {
121 on('session.start', async ($, e, next) => {
122 const result = await next(e)
123 void log($, 'loaded', { version: (await $.session.version()).version })
124 void setTitle($, `spike · loaded ${hhmm()} UTC`)
125 $.clock.every(TICK_MS, () => void tick($))
126 return result
127 })
128
129 // Every row the main conversation keeps: did the agent's result land there?
130 // The note /rename leaves for the model ("The user named this session ...")
131 // is blanked, so a title update costs no context.
132 on('session.append', async ($, e, next) => {
133 const isRenameNote =
134 e.door === 'prompt' &&
135 e.origin.kind === 'plugin' &&
136 JSON.stringify(e.message.content ?? '').includes('named this session')
137 const result = await next(isRenameNote ? { ...e, message: { ...e.message, content: [] } } : e)
138 const isOwnRename = isRenameNote || e.door === 'command'
139 if (test !== undefined && e.agentId === undefined && !isOwnRename) {
140 test.mainRows.push({ door: e.door, origin: e.origin.kind, type: e.message.type, name: e.message.name })
141 }
142 if (e.agentId === undefined) {
143 void log($, 'append', {
144 door: e.door,
145 origin: e.origin.kind,
146 type: e.message.type,
147 name: e.message.name,
148 isMeta: e.message.isMeta,
149 chars: JSON.stringify(e.message.content ?? '').length,
150 storedChars: JSON.stringify(result.message?.content ?? '').length,
151 })
152 }
153 return result
154 }).catch(($, e, next) => next(e))
155
156 on('turn.complete', async ($, e, next) => {
157 const result = await next(e)
158 const agentType = e.agentId === undefined ? undefined : test?.agents[e.agentId]
159 if (test !== undefined && agentType !== undefined) {
160 test.answers.push({ agentType, answer: e.answer.slice(0, 60), durationMs: e.durationMs })
161 } else if (e.agentId === undefined) {
162 if (test !== undefined) test.mainTurns += 1
163 void log($, 'main-turn', { durationMs: e.durationMs, contextTokens: await contextTokens($) })
164 }
165 return result
166 })
167}
168