SLOPSHOPPER

reconcile-spike

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

processtimer
★ 3v0.1.0no licenseupdated 2026-10-18cbundy/dev-system/docs/spikes/reconcile-mod
A shopper browsing a rack in a slop shop
README

reconcile-spike (throwaway, never merged)

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.

CheckPasses when
1 loadedthe log has a loaded line and the title reads spike · loaded ...
2 ticka tick line every minute, with no gaps while the session sits idle
3 titlethe title updates every minute ($.mcp.call, no permission prompt)
4 handbackafter 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 restartafter 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.jsonl
Source 1 files
hooks/register.ts 168 lines
1// 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