SLOPSHOPPER

thimble

A browser workspace beside a Claude Code session for making sense of a corpus of files: chat, files, a canvas of cards and a report. Start it with the…

newnetworkagents
★ 18v0.6.1Apache-2.0updated 2026-10-09safety-research/thimble/plugin
A shopper browsing a rack in a slop shop
README

thimble is an open source Claude Code plugin for human oversight. It opens a workbench where you and Claude make sense of large volumes of agent output together.

[!CAUTION]

  • thimble is in alpha. It changes daily, so expect bugs and rough edges.
  • thimble is not an official Anthropic product.

For feedback, bug reports, or anything else, please reach out to @mjoerke. I would love to hear from you!

Demo

https://github.com/user-attachments/assets/3c21e405-6b8d-4ba6-85a8-24211800596c

Installation

Installing with Claude

claude "install thimble from https://github.com/safety-research/thimble"

Instructions for agents installing thimble on the user's behalf are in CLAUDE.md.

Manual

  1. Download the zip from the latest release.
  2. Unzip it.
  3. Run bash scripts/install.sh inside the unzipped folder.

For a development build, clone the repo and run bash scripts/install.sh.

INSTALL.md covers requirements, updating and troubleshooting.

Usage

  • Run thimble in a directory, just as you would run claude
  • It starts a Claude Code session there with the thimble plugin loaded, inside thimble's sandbox, and prints the dashboard URL.
  • Each run starts a new conversation on the same workspace (cards, report, labels). thimble --continue picks up your last conversation in this folder instead.

thimble's agents

  • The orientation, its critic, the writers, view builds, view reviews and report checks run as subagents of your Claude Code session. Claude Code's agent tray shows them beside the browser's threads, and they run in your session's permission mode and sandbox.
  • A button in the browser, such as Start or Write, starts its agent at once, without a turn of Claude's. Asked in the terminal, Claude starts it with its own Agent call.
  • When an agent finishes, your terminal shows its report arriving and one short line from Claude.
  • Quitting Claude Code stops them, and nothing restarts them on its own: thimble -c and a message in the agent's thread continue one, and Retry starts a view build, review or check again.
  • Models and efforts in Settings apply to the next start, with no restart. Start, /thimble:orient and Claude's start tools take a model and an effort for one run.

From a running Claude Code session

In a session started with thimble, type /thimble to start the thimble server and print the dashboard URL. If a session you started with plain claude doesn't recognise /thimble, quit it and run thimble in that folder. A plain claude session that has thimble's plugin runs without thimble's sandbox and can't start thimble's agents, and /thimble warns about it.

Please note: thimble connects the browser to your Claude Code session through the plugin's hooks. If your settings or your organization turn the plugin's hooks off, thimble connects through a Monitor instead: permission prompts appear only in the terminal, you say /thimble again after /clear (in a session thimble started, you quit and run thimble -c instead, since thimble's sandbox lets out only the /thimble of the session it started), and /thimble prints a warning that says so. thimble's agents start through the plugin's hooks module, so where Claude Code's hooks modules are off (managed settings with disableAllHooks or allowManagedHooksOnly, or a folder Claude Code doesn't trust), they can't start. The launcher and the browser say so, and Claude, its threads, cards and labels still work.

Terminal mode

In terminal mode, thimble draws its work in the Claude Code terminal, with no server or browser: cards and citations appear under Claude's replies, and home, threads, labels, documents and files open in a panel beside the chat. Views still open in the browser, and thimble mode browser switches a folder back (INSTALL.md).

terminal mode: cards under a reply, and the citation panel beside the chat

cd <directory you want to analyze>
thimble mode terminal
thimble

Commands

Inside a Claude Code session

CommandWhat it does
/thimblestart the thimble server and print the dashboard URL
/thimble fresharchive this workspace and open an empty one
/thimble restore [<name>]bring an archived workspace back; with no name, list them
/thimble statusone line: server, orientation, queue
/thimble fixin a development install, repair a server that will not start
/thimble feedbackwrite a problem report (a zip), even with the server down
/thimble:ask <thread> [message]send a message to a thread, as its composer in the browser would
/thimble:orient [focus] [flags]start an orientation, with Start's switches, model and effort as flags
`/thimble:label <name> [definition] [kind=regex\code\prompt] [paths=<glob>,…] [values=a,b] [limit=N]`define a label and apply it to the corpus's records
`/thimble:write [report\slides\story\<type>] [request]`start a writer on a document, the report when none is named

From a shell (thimble help lists these)

CommandWhat it does
thimblestart thimble in this folder; thimble -c continues the last thimble session here
thimble demo [<name>...]download public datasets (collusion-wiki, mythos-5, transluce-urlquery) from their publishers, asking before each, and open the start page that lists them in the browser, each on an orientation run ahead of time where demos/ has one; it starts no Claude Code session (--attach starts one; cd <folder> && thimble attaches one later, thimble -c continues). thimble demo --export <workspace> <out> writes a workspace whole, with the transcripts of its sessions, and lists what it holds (INSTALL.md)
thimble listlist the workspaces by id (each folder's, and its archived runs), when each was last used and its open sessions
thimble purge <id>... [-y] [--dry-run]delete workspaces or archived runs by id and print what was deleted (--dry-run only shows what would go); never your data folder or Claude Code's transcripts
`thimble extension add <folder\git URL>`add an extension and switch it on, after showing what it gives
`thimble extension list\on\off\remove [<name>]`list the extensions, switch one on or off everywhere, or remove it
`thimble mode [browser\terminal] [--default]`where thimble in this folder shows its work: in the browser, or in the terminal with no server (--default: every folder without a mode of its own) (INSTALL.md)
`thimble plugin on\off\status`thimble in every Claude Code session, or only in the sessions thimble starts
thimble doctorwhat is installed and running, and what is wrong
thimble feedback ["<what went wrong>"]write a problem report (a zip) and say where to send it; the top bar's bug icon does the same
thimble updateupdate to the latest release
thimble uninstalluninstall the package

A development install (a git clone) also has thimble fix and thimble revert, and the commands for running a checkout; CONTRIBUTING.md lists them.

Requirements

Claude Code (tested with 2.1.295), with its hooks modules on, macOS or Linux, and Python 3.12+ (uv recommended). Node 20+ is needed for custom views (the viewers the dev agent builds for your data), for the sandbox card code and code tickets run in, and for a development build. INSTALL.md has the details.

Security and privacy

  • thimble is a research prototype. Its server has no login, so any program on your machine can use it. Claude's notebook code runs in a sandbox that keeps the network, so it can reach local services, thimble's server among them: treat a corpus like code you are about to run.
  • Your data stays with you. The server runs on localhost. What leaves your machine is what Claude Code sends to the model and what Claude's notebook code or Claude Code's web tools reach on the network, as in any Claude Code session. A video's narration is read by a voice on your machine.
  • Auth and billing work through Claude Code; thimble doesn't touch them.
  • Report security issues privately to @mjoerke.

License

Apache-2.0; see LICENSE.

Source 1 files
hooks/thimble.ts 825 lines
1// thimble's hooks module: how a click in thimble's browser starts, messages and stops one of thimble's subagents in
2// the analyst's Claude Code session with no turn of main, and how a typed start runs on exactly the model and effort
3// its request names. The server's side is backend/app/module_bridge.py; the tests are thimble.test.ts beside this file.
4//
5// Scope. It acts only in thimble's launched interactive main and checks that before its first fetch or registration:
6// `isInteractive` first (an open long poll would hold every `claude -p` run open), then THIMBLE_LAUNCHED set and
7// THIMBLE_NO_MODULE unset, then a server named by <THIMBLE_HOME>/server.json that accepts its hello for this folder and
8// session (only main's, inside thimble's fence). Anywhere else it registers nothing. It starts no process: $.http.fetch
9// only, every request proving the token of server.json and every answer proving it back (app/hook_auth.py), so a
10// process on the port can neither hand it requests nor read them, and nothing it does runs outside the sandbox.
11//
12// What it does once the server accepts it:
13// - registers thimble's roles and thimble:orient-helper from GET /api/module/roles (each with a full model id and an
14//   explicit effort, from Settings); when the server is up at session start this happens inside session.start, so the
15//   types are in main's first agent listing;
16// - asks Claude Code's permission decision for a write it never makes every PLAN_POLL_MS and tells the server when main
17//   went into plan mode or out of it (POST /api/module/mode), which no event of Claude Code's says while main is idle;
18// - holds GET /api/module/next and handles the requests one at a time, in order, so a role's registration and the
19//   spawn that needs it are never split by another request: register (all roles again), spawn (register the role with
20//   the run's values if they differ, $.agent.spawn with no `model`, then the one-line note the server rendered), send
21//   (register if needed, then SendMessage), stop (TaskStop of the agent and of the shells the server names), note
22//   ($.session.append). It never starts, sends or stops anything the server did not ask for;
23// - agent.spawn: an Agent call for one of thimble's roles whose prompt's first line names a typed request, main's or a
24//   subagent's (a subagent of main's own may start thimble's agents, as Claude Code lets a subagent start subagents;
25//   a thread's fork is refused at its start tool, tools._as_caller), gets that
26//   request's full model id, and the agent it starts that request's effort, which turn.step sets on its every request;
27//   a child of such an agent whose type is not thimble's gets the same effort (its model it inherits already). These
28//   hooks never see a run of an agent this module started, which is why clicks register instead;
29// - turn.complete: posts the end of each run of an agent it started (POST /api/module/ended), with why it ended and,
30//   for a refusal, what the API said of it, since its other hooks skip those agents;
31// - session.end: on /clear and /resume keeps its poll, waits for the new session id, says hello again under it,
32//   refetches what the workspace's record holds and appends to the new main one note per running thimble agent; on
33//   any other reason it stops polling.
34// Its state lives in this module's memory, which /clear and /resume keep, with subagents.json as the record it
35// refetches at session start and after a change of session; never in $.state, which both commands reset.
36//
37// Terminal mode (THIMBLE_WS names the workspace folder and its trusted/launch.json says `mode: terminal`) has no
38// server, and the module makes no HTTP request there, even when server.json names a live server. Its scope check reads
39// launch.json: terminal mode, `fenced: true`, and a session that is $.session.id() or one --rekey moved it to (the
40// moves subagents.json records). It reads the roles from trusted/roles.json and registers them again whenever that
41// file changes; it takes the requests addressed to it from subagents.json (entries of `requests` of kind `module`
42// whose `module` is pending, for its session, not past `expires_at`), polling every FILE_POLL_MS, and handles them one
43// at a time as it handles the long poll's; and it writes everything it says to trusted/module.json, which only it
44// writes: its heartbeat every FILE_BEAT_MS, the requests it took, its answers, the ends of the runs it started, main's
45// plan mode as its poll sees it, and what it could not register (backend/app/module_bridge.py has the file's form).
46// After /clear it appends the notes --rekey left for the new main in subagents.json (`module.notes`).
47import type { EngineInterface, Register } from 'claude-code'
48
49type Engine = EngineInterface
50type Json = Record<string, unknown>
51type Effort = string | number
52type Values = { model?: string; effort?: Effort | null }
53type Spec = { name: string; description: string; prompt: string; model?: string; effort?: Effort } & Json
54type Answer = Json
55type Request = { id: string; op: string; args: Json; expires_in?: number }
56type Server = { base: string; token: string }
57type Reply = { status: number; data: Json }
58type Hello = 'ok' | 'wait' | 'refused'
59
60export const PLUGIN = 'thimble'
61// thimble's roles: each names its own model and effort, so a typed run's effort never goes to one of them
62export const ROLES = ['orientation', 'critic', 'writer', 'view-builder', 'view-reviewer', 'check', 'dev-ticket']
63export const RETRY_MS = 2000 // between hellos while no server accepts one, and after a failed poll
64export const SESSION_WAIT_MS = 10000 // how long /clear's new session id is waited for
65export const SESSION_TICK_MS = 25
66const QUICK_MS = 1000 // a poll answered sooner than this with nothing is a server that is stopping
67// Claude Code's text when it runs as many subagents as it allows: the Agent tool's, and $.agent.spawn's when as many of
68// this plugin's spawns run as CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS allows ("thimble: $.agent.spawn refused: 2 spawns are
69// running at once", which it throws; live check L19)
70const LIMIT = /concurrent subagent limit|spawns are running at once/i
71const GONE = /is not running|no task found|could not be resumed|no transcript found/i
72const QUEUED = /queued for delivery/i
73const REQUEST_WORD = /[A-Za-z0-9_-]{8,64}/g // a request id on the first line of a typed start's prompt
74const OFF = new Set(['', '0', 'false', 'no', 'off'])
75// Claude Code tells no plugin of a mode change, so the module asks its permission decision for a write it never makes
76// every PLAN_POLL_MS, and plan mode says so in the reason ("Cannot write to … while in plan mode"); the server hears a
77// change at once, not at main's next turn (live check L21)
78export const PLAN_POLL_MS = 2000
79// never written; outside main's folder and every rule of thimble's fence, whose ask or deny would decide first and say
80// nothing of plan mode (main's corpus is an ask rule)
81const PLAN_PROBE = '/tmp/.thimble-plan-probe'
82const PLAN_REASON = /\bplan mode\b/i
83
84// terminal mode (module note)
85export const FILE_POLL_MS = 500 // how often subagents.json and roles.json are looked at
86export const FILE_BEAT_MS = 2000 // the heartbeat in module.json; module_bridge counts a module live while it is fresh
87const KEPT = 64 // the requests taken, the answers and the ends module.json keeps, the newest
88const TYPED_LIVE = new Set(['', 'pending', 'claimed']) // module_bridge.TYPED_LIVE
89const MODULE_KIND = 'module' // subagent_files.MODULE_KIND
90
91const isObj = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
92const text = (v: unknown): string => (typeof v === 'string' ? v : '')
93const roleOf = (type: string): string => (type.startsWith(`${PLUGIN}:`) && ROLES.includes(type.slice(PLUGIN.length + 1)) ? type.slice(PLUGIN.length + 1) : '')
94
95// --------------------------------------------------------------------------------------------------- the proof
96
97const encoder = new TextEncoder()
98
99async function sha256(data: Uint8Array): Promise<Uint8Array> {
100  return new Uint8Array(await crypto.subtle.digest('SHA-256', data))
101}
102
103function hex(bytes: Uint8Array): string {
104  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
105}
106
107/** HMAC-SHA256 as hex, built on crypto.subtle.digest, the one digest this environment offers (app/hook_auth.py sign). */
108export async function hmac(key: string, message: string): Promise<string> {
109  let k = encoder.encode(key)
110  if (k.length > 64) k = await sha256(k)
111  const block = new Uint8Array(64)
112  block.set(k)
113  const body = encoder.encode(message)
114  const inner = new Uint8Array(64 + body.length)
115  inner.set(block.map(b => b ^ 0x36))
116  inner.set(body, 64)
117  const innerHash = await sha256(inner)
118  const outer = new Uint8Array(64 + innerHash.length)
119  outer.set(block.map(b => b ^ 0x5c))
120  outer.set(innerHash, 64)
121  return hex(await sha256(outer))
122}
123
124/** <THIMBLE_HOME>/server.json's API base and token (`api`, then `port`, as bin/thimble-mcp reads it); null without. */
125async function findServer($: Engine): Promise<Server | null> {
126  const homeDir = (await $.env.get('HOME')) ?? ''
127  let home = (await $.env.get('THIMBLE_HOME')) || `${homeDir}/.thimble`
128  if (home.startsWith('~/')) home = `${homeDir}${home.slice(1)}`
129  let data: unknown
130  try {
131    data = JSON.parse(await $.fs.read(`${home}/server.json`))
132  } catch {
133    return null
134  }
135  if (!isObj(data) || !text(data.token)) return null
136  const port = Number(data.port)
137  const base = text(data.api) ? text(data.api).replace(/\/+$/, '') : Number.isInteger(port) && port > 0 ? `http://127.0.0.1:${port}` : ''
138  return base ? { base, token: text(data.token) } : null
139}
140
141// --------------------------------------------------------------------------------------------------- the module
142// One State per load of the module (register runs once per load); the functions below take `$` and it. Claude Code's
143// check of a module (claude plugin validate) allows `$` to pass only to plain functions, never to a method.
144
145type Ended = { n: number; agentId: string; answer: string; reason: string; refusal?: Json; at: number }
146type Out = {
147  session: string; version: string; load: string; beat: number; plan: boolean | null; plan_at: number; problem: string
148  taken: string[]; answers: Record<string, Answer>; ended: Ended[]; gone?: boolean
149}
150
151type State = {
152  active: boolean // in thimble's launched interactive main (the scope check passed) and not ended
153  file: boolean // terminal mode: the trusted folder, not the server (module note)
154  ws: string // the workspace folder, in terminal mode
155  out: Out // what module.json holds, in terminal mode
156  writing: Promise<void> // module.json's writes, one at a time
157  stamps: Record<string, string> // the files' stamps (mtime and size) as last read, in terminal mode
158  n: number // the ends written to module.json, in terminal mode
159  cwd: string
160  session: string // main's session id, as the server accepted it
161  version: string
162  gen: number // a poll whose generation is past stops
163  server: Server | null
164  roles: Record<string, Spec> // the roles as Settings name them (GET /api/module/roles)
165  registered: Record<string, Spec> // what each role is registered with now
166  efforts: Record<string, Effort> // agent id -> the effort turn.step sets (typed runs and their children)
167  requests: Record<string, { role: string; values: Values }> // typed starts waiting for main's Agent call
168  started: Set<string> // agents this module spawned: their turn.complete is posted
169  queue: Promise<void> // the requests, one at a time
170  helloing: { sid: string; said: Promise<Hello> } | null
171  leaving: string // the session /clear or /resume is taking main away from, until the new one is known
172  plan: boolean | null // whether main's session was in plan mode at the last look, as the server heard it
173}
174
175function fresh(): State {
176  return {
177    active: false, cwd: '', session: '', version: '', gen: 0, server: null, roles: {}, registered: {}, efforts: {},
178    requests: {}, started: new Set(), queue: Promise.resolve(), helloing: null, leaving: '', plan: null,
179    file: false, ws: '', writing: Promise.resolve(), stamps: {}, n: 0,
180    out: { session: '', version: '', load: '', beat: 0, plan: null, plan_at: 0, problem: '', taken: [], answers: {}, ended: [] },
181  }
182}
183
184const where = (m: State): string => new URLSearchParams({ cwd: m.cwd, session: m.session }).toString()
185
186function stop(m: State): void {
187  m.active = false
188  m.gen += 1
189}
190
191// ------------------------------------------------------------------------------------------------ the server
192
193async function call($: Engine, m: State, method: string, path: string, body?: Json): Promise<Reply | null> {
194  const server = m.server ?? (m.server = await findServer($))
195  if (!server) return null
196  const nonce = hex(crypto.getRandomValues(new Uint8Array(16)))
197  const headers: Record<string, string> = { 'x-thimble-nonce': nonce, 'x-thimble-auth': await hmac(server.token, `hook:${nonce}`) }
198  let payload: string | undefined
199  if (body !== undefined) {
200    headers['content-type'] = 'application/json'
201    payload = JSON.stringify(body)
202  }
203  let r
204  try {
205    r = await $.http.fetch(`${server.base}${path}`, { method, headers, body: payload })
206  } catch {
207    m.server = null // the next call reads server.json again: a restarted server may have another port or token
208    return null
209  }
210  if ((r.headers['x-thimble-proof'] ?? '') !== (await hmac(server.token, `server:${nonce}`))) {
211    m.server = null // not thimble's server: believe nothing it says
212    return null
213  }
214  let data: unknown = {}
215  try {
216    data = r.text ? JSON.parse(r.text) : {}
217  } catch {
218    data = {}
219  }
220  return { status: r.status, data: isObj(data) ? data : {} }
221}
222
223/** One hello per session at a time: 'ok' when the server accepted this session, 'refused' when it never will, else
224 *  'wait'. An answer for a session this module has since left (/clear came meanwhile) counts as 'wait'. */
225function hello($: Engine, m: State, problem = ''): Promise<Hello> {
226  const sid = m.session
227  if (m.helloing && m.helloing.sid === sid && !problem) return m.helloing.said
228  const said = (async (): Promise<Hello> => {
229    const r = await call($, m, 'POST', '/api/module/hello', { cwd: m.cwd, session: sid, version: m.version, problem })
230    if (m.session !== sid || !r) return 'wait'
231    if (r.status === 200 && r.data.ok === true) return 'ok'
232    if (r.status === 403) return sid === m.leaving ? 'wait' : 'refused' // the server moved main first
233    return 'wait' // 404: the folder is no workspace yet; a server that is starting
234  })().finally(() => {
235    if (m.helloing?.said === said) m.helloing = null
236  })
237  if (!problem) m.helloing = { sid, said }
238  return said
239}
240
241async function fetchRoles($: Engine, m: State): Promise<boolean> {
242  const r = await call($, m, 'GET', `/api/module/roles?${where(m)}`)
243  if (!r || r.status !== 200 || !isObj(r.data.roles)) return false
244  const roles: Record<string, Spec> = {}
245  for (const [name, spec] of Object.entries(r.data.roles)) {
246    if (isObj(spec) && text(spec.model) && text(spec.prompt)) roles[name] = { ...spec, name, description: text(spec.description), prompt: text(spec.prompt), background: true } as Spec
247  }
248  m.roles = roles
249  return true
250}
251
252/** What subagents.json holds: the per-run efforts, the typed starts, the agents this module started. The notes it
253 *  answers for the running agents are returned, for a new main after /clear or /resume. */
254async function fetchState($: Engine, m: State): Promise<string[]> {
255  if (m.file) {
256    await fetchStateFile($, m)
257    return []
258  }
259  const r = await call($, m, 'GET', `/api/module/state?${where(m)}`)
260  if (!r || r.status !== 200) return []
261  if (isObj(r.data.efforts)) {
262    for (const [id, effort] of Object.entries(r.data.efforts)) {
263      if (typeof effort === 'string' || typeof effort === 'number') m.efforts[id] = effort
264    }
265  }
266  if (isObj(r.data.requests)) {
267    const requests: State['requests'] = {}
268    for (const [id, req] of Object.entries(r.data.requests)) {
269      if (isObj(req) && text(req.role)) requests[id] = { role: text(req.role), values: isObj(req.values) ? (req.values as Values) : {} }
270    }
271    m.requests = requests
272  }
273  if (isObj(r.data.agents)) {
274    for (const [id, agent] of Object.entries(r.data.agents)) if (isObj(agent) && agent.plugin_started === true) m.started.add(id)
275  }
276  return Array.isArray(r.data.notes) ? r.data.notes.filter((n): n is string => typeof n === 'string' && n !== '') : []
277}
278
279async function registerAll($: Engine, m: State): Promise<void> {
280  const problems: string[] = []
281  m.registered = {}
282  for (const [name, spec] of Object.entries(m.roles)) {
283    try {
284      await $.agent.register(spec as Parameters<Engine['agent']['register']>[0])
285      m.registered[name] = spec
286    } catch (err) {
287      problems.push(`${PLUGIN}:${name}: ${String(err)}`)
288    }
289  }
290  if (problems.length) await say($, m, `Claude Code did not register ${problems.join('; ')}`)
291}
292
293/** What the module could not do: in the hello's `problem` (browser mode), or module.json's (terminal mode). */
294async function say($: Engine, m: State, problem: string): Promise<void> {
295  if (!m.file) {
296    await hello($, m, problem)
297    return
298  }
299  m.out.problem = problem
300  await writeOut($, m)
301}
302
303// ------------------------------------------------------------------------------------------------ the session
304
305async function begin($: Engine, m: State, cwd: string): Promise<void> {
306  m.cwd = cwd
307  m.session = await $.session.id()
308  try {
309    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
310    m.version = isObj(manifest) ? text(manifest.version) : ''
311  } catch {
312    m.version = ''
313  }
314  // a server that is up now: hello, roles and registrations inside session.start, for main's first agent listing
315  const first = await connect($, m)
316  if (first === 'refused') return stop(m)
317  const gen = m.gen
318  void (async () => {
319    let state: Hello = first
320    while (state === 'wait' && m.active && gen === m.gen) {
321      await $.clock.sleep(RETRY_MS)
322      state = await connect($, m)
323    }
324    if (state === 'ok' && gen === m.gen) {
325      void watchPlan($, m, gen)
326      await poll($, m, gen)
327    } else if (state === 'refused') stop(m)
328  })()
329}
330
331/** Every PLAN_POLL_MS: whether main's session is in plan mode now (Claude Code's decision for a write the module never
332 *  makes, PLAN_REASON), posted to the server when it changed. */
333async function watchPlan($: Engine, m: State, gen: number): Promise<void> {
334  while (m.active && gen === m.gen) {
335    await $.clock.sleep(PLAN_POLL_MS)
336    if (!m.active || gen !== m.gen) return
337    let plan: boolean
338    try {
339      const got = await $.tool.check({ tool: 'Write', input: { file_path: PLAN_PROBE, content: '' } } as Parameters<Engine['tool']['check']>[0])
340      plan = PLAN_REASON.test(text((got as Json).reason))
341    } catch {
342      continue
343    }
344    if (plan === m.plan) continue
345    if (m.file) {
346      m.plan = plan
347      m.out.plan = plan
348      m.out.plan_at = Date.now()
349      await writeOut($, m)
350      continue
351    }
352    const r = await call($, m, 'POST', '/api/module/mode', { cwd: m.cwd, session: m.session, plan })
353    if (r?.status === 200) m.plan = plan
354  }
355}
356
357async function connect($: Engine, m: State): Promise<Hello> {
358  const said = await hello($, m)
359  if (said !== 'ok') return said
360  await fetchState($, m)
361  if (await fetchRoles($, m)) await registerAll($, m)
362  else await hello($, m, 'thimble’s module could not fetch the roles it registers')
363  return 'ok'
364}
365
366async function poll($: Engine, m: State, gen: number): Promise<void> {
367  while (m.active && gen === m.gen) {
368    const asked = Date.now()
369    const r = await call($, m, 'GET', `/api/module/next?${where(m)}`)
370    if (!m.active || gen !== m.gen) return
371    if (r?.status === 200 && isObj(r.data.request)) {
372      take($, m, r.data.request as Request)
373      continue
374    }
375    if (r?.status === 204) {
376      // a server holds a poll up to 25 s; one that answers at once is stopping, and is not asked again at once
377      if (Date.now() - asked < QUICK_MS) await $.clock.sleep(RETRY_MS)
378      continue
379    }
380    if (r?.status === 409 && m.leaving && m.session === m.leaving) {
381      await $.clock.sleep(SESSION_TICK_MS) // main is moving: follow says hello under the new id
382      continue
383    }
384    if (r?.status === 409) {
385      // not the accepted session (a server that restarted, or main moved): say hello again
386      const said = await hello($, m)
387      if (said === 'refused') return stop(m)
388      if (said === 'ok') continue
389    }
390    await $.clock.sleep(RETRY_MS)
391  }
392}
393
394function take($: Engine, m: State, req: Request): void {
395  const received = Date.now()
396  m.queue = m.queue
397    .then(async () => {
398      const late = typeof req.expires_in === 'number' && Date.now() - received > req.expires_in
399      const answer = late ? { error: 'the request expired before thimble’s module reached it' } : await handle($, m, req)
400      await call($, m, 'POST', '/api/module/result', { cwd: m.cwd, session: m.session, id: req.id, answer })
401    })
402    .catch(() => undefined)
403}
404
405async function handle($: Engine, m: State, req: Request): Promise<Answer> {
406  const a = isObj(req.args) ? req.args : {}
407  const values = isObj(a.values) ? (a.values as Values) : undefined
408  try {
409    switch (req.op) {
410      case 'register':
411        if (!(await (m.file ? readRoles($, m) : fetchRoles($, m)))) return { error: 'the roles could not be fetched' }
412        await registerAll($, m)
413        return { ok: true }
414      case 'spawn':
415        return await spawn($, m, text(a.role), text(a.prompt), text(a.description), values, text(a.note))
416      case 'send':
417        return await send($, m, text(a.agent), text(a.text), text(a.role), values)
418      case 'stop':
419        return await stopAgent($, text(a.agent), Array.isArray(a.shells) ? a.shells.map(text).filter(Boolean) : [])
420      case 'note':
421        return await note($, text(a.text))
422      default:
423        return { error: `thimble’s module has no op ${req.op}` }
424    }
425  } catch (err) {
426    const why = String(err)
427    return LIMIT.test(why) ? { limit: why } : { error: why }
428  }
429}
430
431/** The role registered with `values` (the run's model and effort) when they differ from its registration now. */
432async function ensure($: Engine, m: State, role: string, values: Values | undefined): Promise<void> {
433  const base = m.roles[role]
434  if (!base) throw new Error(`thimble’s role ${role} is not registered in this session`)
435  const want: Spec = { ...base }
436  if (values && text(values.model)) want.model = text(values.model)
437  if (values && 'effort' in values) {
438    if (values.effort === null || values.effort === undefined || values.effort === '') delete want.effort
439    else want.effort = values.effort
440  }
441  const have = m.registered[role]
442  if (have && have.model === want.model && have.effort === want.effort && have.prompt === want.prompt) return
443  await $.agent.register(want as Parameters<Engine['agent']['register']>[0])
444  m.registered[role] = want
445}
446
447async function spawn($: Engine, m: State, role: string, prompt: string, description: string, values: Values | undefined, line: string): Promise<Answer> {
448  await ensure($, m, role, values)
449  // no `model`: a spawn takes only an alias; the registration carries the full id
450  const r = await $.agent.spawn({ subagentType: `${PLUGIN}:${role}`, prompt, description })
451  if (r.deny !== undefined) return LIMIT.test(r.deny) ? { limit: r.deny } : { deny: r.deny }
452  if (!r.agentId) return { error: 'Claude Code started no agent' }
453  m.started.add(r.agentId)
454  if (line) await note($, line.split('{agent}').join(r.agentId))
455  return { agentId: r.agentId, model: r.model }
456}
457
458async function send($: Engine, m: State, agent: string, message: string, role: string, values: Values | undefined): Promise<Answer> {
459  if (role && m.roles[role]) await ensure($, m, role, values)
460  const r = await $.tool.call({ tool: 'SendMessage', to: agent, message } as Parameters<Engine['tool']['call']>[0])
461  if (r.deny !== undefined) return { deny: r.deny }
462  const said = text(r.text)
463  if (r.isError) return { error: said || 'SendMessage failed', ...(GONE.test(said) ? { gone: true } : {}) }
464  const result = (r as { result?: unknown }).result
465  if (isObj(result) && result.success === false) {
466    const why = text(result.message) || said
467    return { error: why, ...(GONE.test(why) ? { gone: true } : {}) }
468  }
469  return { agentId: agent, text: said, queued: QUEUED.test(said) }
470}
471
472async function stopAgent($: Engine, agent: string, shells: string[] = []): Promise<Answer> {
473  const r = await $.tool.call({ tool: 'TaskStop', task_id: agent } as Parameters<Engine['tool']['call']>[0])
474  // the background shells the agent started, which Claude Code's TaskStop of the agent leaves running; one that has
475  // ended already answers an error, which changes nothing
476  for (const shell of shells) await $.tool.call({ tool: 'TaskStop', task_id: shell } as Parameters<Engine['tool']['call']>[0]).catch(() => undefined)
477  if (r.deny !== undefined) return { deny: r.deny }
478  const said = text(r.text)
479  if (r.isError) return { error: said || 'TaskStop failed', ...(GONE.test(said) ? { gone: true } : {}) }
480  return { agentId: agent, text: said }
481}
482
483async function note($: Engine, line: string): Promise<Answer> {
484  if (!line) return { error: 'an empty note' }
485  const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: line }] } })
486  return r.deny !== undefined ? { deny: r.deny } : { ok: true }
487}
488
489/** The typed start a prompt's first line names, for `role`: from memory, else from the record fetched again. */
490async function typedStart($: Engine, m: State, prompt: string, role: string): Promise<{ id: string; values: Values } | null> {
491  const words = (prompt.split('\n', 1)[0] ?? '').match(REQUEST_WORD) ?? []
492  if (!words.length) return null
493  const find = () => {
494    for (const id of words) {
495      const req = m.requests[id]
496      if (req && req.role === role) return { id, values: req.values }
497    }
498    return null
499  }
500  const known = find()
501  if (known) return known
502  await fetchState($, m)
503  return find()
504}
505
506/** After /clear or /resume: the new session id, a hello under it, the record again, and a note per running agent. */
507async function follow($: Engine, m: State, old: string): Promise<void> {
508  m.leaving = old
509  let sid = old
510  for (let waited = 0; waited < SESSION_WAIT_MS && sid === old; waited += SESSION_TICK_MS) {
511    await $.clock.sleep(SESSION_TICK_MS)
512    sid = await $.session.id()
513  }
514  if (m.leaving === old) m.leaving = ''
515  if (!m.active || sid === old) return
516  m.session = sid
517  let said = await hello($, m)
518  while (said === 'wait' && m.active && m.session === sid) {
519    await $.clock.sleep(RETRY_MS)
520    said = await hello($, m)
521  }
522  if (said === 'refused') return stop(m)
523  if (said !== 'ok' || m.session !== sid) return
524  for (const line of await fetchState($, m)) await note($, line)
525}
526
527/** What main's (or an agent's) Agent call starts with: a typed start's model, and the effort its agent and that
528 *  agent's children of no thimble type run at. A typed start is found by the request its prompt's first line names,
529 *  whoever makes the call: main, a subagent of main's own whose start tool call made the request, or the
530 *  orientation for its critic. Never fails the call: a lookup that fails changes nothing. */
531async function spawning($: Engine, m: State, e: { subagentType?: string; parentAgentId?: string; prompt?: string }): Promise<{ model?: string; effort?: Effort; typed?: string }> {
532  try {
533    const type = e.subagentType ?? ''
534    const role = roleOf(type)
535    if (role) {
536      const typed = await typedStart($, m, e.prompt ?? '', role)
537      if (!typed) return {}
538      const effort = typed.values.effort
539      return {
540        typed: typed.id,
541        ...(text(typed.values.model) ? { model: text(typed.values.model) } : {}),
542        ...(effort !== null && effort !== undefined && effort !== '' ? { effort } : {}),
543      }
544    }
545    if (e.parentAgentId && !type.startsWith(`${PLUGIN}:`) && m.efforts[e.parentAgentId] !== undefined) return { effort: m.efforts[e.parentAgentId] }
546  } catch {
547    // the call goes on as Claude Code made it
548  }
549  return {}
550}
551
552// ------------------------------------------------------------------------------------------------ terminal mode
553
554async function readJson($: Engine, path: string): Promise<Json | null> {
555  try {
556    const data = JSON.parse(await $.fs.read(path))
557    return isObj(data) ? data : null
558  } catch {
559    return null
560  }
561}
562
563/** A file's stamp (mtime and size), '' when it is missing. */
564async function stamp($: Engine, path: string): Promise<string> {
565  try {
566    const st = await $.fs.stat(path)
567    return `${st.mtimeMs}:${st.size}`
568  } catch {
569    return ''
570  }
571}
572
573const trusted = (m: State, name: string): string => `${m.ws}/trusted/${name}`
574
575/** Session `sid` followed through the moves --rekey recorded (subagents.json `module.rekeyed`). */
576function movedTo(state: Json, sid: string): string {
577  const moves = isObj(state.module) && isObj(state.module.rekeyed) ? (state.module.rekeyed as Json) : {}
578  const seen = new Set<string>()
579  while (sid && typeof moves[sid] === 'string' && !seen.has(sid)) {
580    seen.add(sid)
581    sid = moves[sid] as string
582  }
583  return sid
584}
585
586/** module.json written whole, one write at a time, with a fresh heartbeat (module note). */
587function writeOut($: Engine, m: State): Promise<void> {
588  m.writing = m.writing
589    .then(async () => {
590      m.out.beat = Date.now()
591      await $.fs.write(trusted(m, 'module.json'), JSON.stringify(m.out))
592    })
593    .catch(() => undefined)
594  return m.writing
595}
596
597async function readRoles($: Engine, m: State): Promise<boolean> {
598  const got = await readJson($, trusted(m, 'roles.json'))
599  if (!got || !isObj(got.roles)) return false
600  const roles: Record<string, Spec> = {}
601  for (const [name, spec] of Object.entries(got.roles)) {
602    if (isObj(spec) && text(spec.model) && text(spec.prompt)) roles[name] = { ...spec, name, description: text(spec.description), prompt: text(spec.prompt), background: true } as Spec
603  }
604  m.roles = roles
605  return true
606}
607
608/** module_bridge._efforts: the per-run efforts of the typed starts and their children of no thimble type, then the
609 *  record's own. */
610function effortsOf(state: Json): Record<string, Effort> {
611  const agents = isObj(state.agents) ? Object.entries(state.agents).filter((x): x is [string, Json] => isObj(x[1])) : []
612  const out: Record<string, Effort> = {}
613  for (const [id, e] of agents) {
614    const effort = isObj(e.values) ? e.values.effort : undefined
615    if (text(e.type).startsWith(`${PLUGIN}:`) && !e.plugin_started && (typeof effort === 'string' || typeof effort === 'number') && effort !== '') out[id] = effort
616  }
617  for (let changed = true; changed;) {
618    changed = false
619    for (const [id, e] of agents) {
620      const parent = text(e.parent)
621      if (!(id in out) && parent in out && !text(e.type).startsWith(`${PLUGIN}:`)) {
622        out[id] = out[parent]
623        changed = true
624      }
625    }
626  }
627  if (isObj(state.efforts)) for (const [id, v] of Object.entries(state.efforts)) if ((typeof v === 'string' || typeof v === 'number') && v !== '') out[id] = v
628  return out
629}
630
631/** What fetchState fetches in browser mode, from subagents.json: the per-run efforts, the typed starts waiting for an
632 *  Agent call (module_bridge._typed), the agents this module started. */
633async function fetchStateFile($: Engine, m: State): Promise<Json> {
634  const state = (await readJson($, trusted(m, 'subagents.json'))) ?? {}
635  Object.assign(m.efforts, effortsOf(state))
636  const requests: State['requests'] = {}
637  for (const table of ['pending', 'requests']) {
638    if (!isObj(state[table])) continue
639    for (const [id, r] of Object.entries(state[table] as Json)) {
640      if (!isObj(r) || r.route !== 'typed' || (text(r.kind) || 'start') !== 'start' || !TYPED_LIVE.has(text(r.state))) continue
641      requests[text(r.id) || id] = { role: text(r.role).replace(/^thimble:/, ''), values: isObj(r.values) ? (r.values as Values) : {} }
642    }
643  }
644  m.requests = requests
645  if (isObj(state.agents)) for (const [id, a] of Object.entries(state.agents)) if (isObj(a) && a.plugin_started === true) m.started.add(id)
646  return state
647}
648
649/** Terminal mode's start (module note): the scope check against launch.json, the roles from roles.json, the record,
650 *  module.json, then the poll of the files and the plan poll. */
651async function fileBegin($: Engine, m: State, cwd: string, ws: string, launch: Json): Promise<void> {
652  m.file = true
653  m.ws = ws.replace(/\/+$/, '')
654  m.cwd = cwd
655  m.session = await $.session.id()
656  const state = (await readJson($, trusted(m, 'subagents.json'))) ?? {}
657  if (launch.fenced !== true || !text(launch.session) || movedTo(state, text(launch.session)) !== m.session) return stop(m)
658  try {
659    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
660    m.version = isObj(manifest) ? text(manifest.version) : ''
661  } catch {
662    m.version = ''
663  }
664  m.out = { ...m.out, session: m.session, version: m.version, load: hex(crypto.getRandomValues(new Uint8Array(8))), gone: false }
665  await fetchStateFile($, m)
666  m.stamps.roles = await stamp($, trusted(m, 'roles.json'))
667  if (await readRoles($, m)) await registerAll($, m)
668  else m.out.problem = 'thimble’s module could not read the roles it registers (trusted/roles.json)'
669  await writeOut($, m)
670  const gen = m.gen
671  void watchPlan($, m, gen)
672  void pollFiles($, m, gen)
673}
674
675/** Every FILE_POLL_MS: roles.json registered again when it changed, the requests subagents.json addresses to this
676 *  module taken when it changed, and the heartbeat every FILE_BEAT_MS (module note). */
677async function pollFiles($: Engine, m: State, gen: number): Promise<void> {
678  let beat = Date.now()
679  while (m.active && gen === m.gen) {
680    try {
681      const roles = await stamp($, trusted(m, 'roles.json'))
682      if (roles && roles !== m.stamps.roles) {
683        m.stamps.roles = roles
684        if (await readRoles($, m)) {
685          m.queue = m.queue.then(() => registerAll($, m)).catch(() => undefined)
686          await m.queue
687        }
688      }
689      const state = await stamp($, trusted(m, 'subagents.json'))
690      if (state && state !== m.stamps.state) {
691        m.stamps.state = state
692        await takeFile($, m)
693      }
694      if (Date.now() - beat >= FILE_BEAT_MS) {
695        beat = Date.now()
696        await writeOut($, m)
697      }
698    } catch {
699      // the next look tries again
700    }
701    await $.clock.sleep(FILE_POLL_MS)
702  }
703}
704
705/** The requests subagents.json addresses to this module that it has not taken, oldest first, each taken (module.json
706 *  `taken`) and handled in turn, its answer written under its id. */
707async function takeFile($: Engine, m: State): Promise<void> {
708  const state = await readJson($, trusted(m, 'subagents.json'))
709  if (!state || !isObj(state.requests)) return
710  const now = Date.now()
711  const mine = Object.entries(state.requests)
712    .filter((x): x is [string, Json] => isObj(x[1]) && x[1].kind === MODULE_KIND && x[1].module === 'pending' && !m.out.taken.includes(x[0])
713      && (!text(x[1].session) || text(x[1].session) === m.session) && typeof x[1].expires_at === 'number' && x[1].expires_at * 1000 > now)
714    .sort((a, b) => Number(a[1].asked_at ?? 0) - Number(b[1].asked_at ?? 0))
715  if (!mine.length) return
716  m.out.taken = [...m.out.taken, ...mine.map(([id]) => id)].slice(-KEPT)
717  await writeOut($, m)
718  for (const [id, r] of mine) {
719    const req: Request = { id, op: text(r.op), args: isObj(r.args) ? r.args : {} }
720    const due = Number(r.expires_at) * 1000
721    m.queue = m.queue
722      .then(async () => {
723        const answer = Date.now() > due ? { error: 'the request expired before thimble’s module reached it' } : await handle($, m, req)
724        const answers = { ...m.out.answers, [id]: answer }
725        m.out.answers = Object.fromEntries(Object.entries(answers).slice(-KEPT))
726        await writeOut($, m)
727      })
728      .catch(() => undefined)
729  }
730}
731
732/** After /clear or /resume in terminal mode: the new session id, the move --rekey recorded to it, module.json under it,
733 *  the record again, and the notes --rekey left for the new main (subagents.json `module.notes`). */
734async function followFile($: Engine, m: State, old: string): Promise<void> {
735  let sid = old
736  for (let waited = 0; waited < SESSION_WAIT_MS && sid === old; waited += SESSION_TICK_MS) {
737    await $.clock.sleep(SESSION_TICK_MS)
738    sid = await $.session.id()
739  }
740  if (!m.active || sid === old) return
741  let state: Json = {}
742  for (let waited = 0; waited < SESSION_WAIT_MS; waited += RETRY_MS / 4) {
743    state = (await readJson($, trusted(m, 'subagents.json'))) ?? {}
744    if (movedTo(state, old) === sid) break
745    await $.clock.sleep(RETRY_MS / 4)
746  }
747  if (movedTo(state, old) !== sid) {
748    stop(m) // no record says the new session is main's: idle, as a refused hello leaves it
749    return
750  }
751  m.session = sid
752  m.out.session = sid
753  await writeOut($, m)
754  await fetchStateFile($, m)
755  const notes = isObj(state.module) && isObj(state.module.notes) ? (state.module.notes as Json) : {}
756  if (text(notes.session) === sid && Array.isArray(notes.lines)) {
757    for (const line of notes.lines) if (typeof line === 'string' && line) await note($, line)
758  }
759}
760
761export const register: Register = on => {
762  const m = fresh()
763
764  on('session.start', async ($, e, next) => {
765    const r = await next(e)
766    // the scope check, before any fetch or registration: interactive first, then launched by thimble, module not off
767    if (!e.isInteractive) return r
768    if (!(await $.env.get('THIMBLE_LAUNCHED'))) return r
769    if (!OFF.has(((await $.env.get('THIMBLE_NO_MODULE')) ?? '').trim().toLowerCase())) return r
770    // terminal mode: THIMBLE_WS's launch.json says so (module note); browser mode otherwise
771    const ws = (await $.env.get('THIMBLE_WS')) ?? ''
772    const launch = ws ? await readJson($, `${ws}/trusted/launch.json`) : null
773    if (ws && launch === null) return r // a workspace the launcher named whose launch.json cannot be read: idle
774    m.active = true
775    if (launch !== null && launch.mode === 'terminal') await fileBegin($, m, e.cwd, ws, launch)
776    else await begin($, m, e.cwd)
777    return r
778  })
779
780  on('agent.spawn', async ($, e, next) => {
781    if (!m.active) return next(e)
782    const got = await spawning($, m, e)
783    const r = await next(got.model ? { ...e, model: got.model } : e)
784    if (r.agentId) {
785      if (got.effort !== undefined) m.efforts[r.agentId] = got.effort
786      if (got.typed) delete m.requests[got.typed]
787    }
788    return r
789  }).catch(($, e, next) => next(e)) // not a guard: if it fails, the call goes on as Claude Code made it
790
791  on('turn.step', async function* ($, e, next) {
792    const effort = m.active && e.agentId !== undefined ? m.efforts[e.agentId] : undefined
793    // a request with no effort is to a model without one, which takes none
794    return yield* next(effort !== undefined && e.effort !== undefined ? { ...e, effort: effort as typeof e.effort } : e)
795  })
796
797  on('turn.complete', async ($, e, next) => {
798    const r = await next(e)
799    if (m.active && e.agentId !== undefined && m.started.has(e.agentId)) {
800      const refusal = e.reason === 'refusal' && isObj(e.refusal) ? { refusal: e.refusal } : {}
801      if (m.file) {
802        m.out.ended = [...m.out.ended, { n: ++m.n, agentId: e.agentId, answer: text(e.answer), reason: text(e.reason), ...refusal, at: Date.now() }].slice(-KEPT)
803        void writeOut($, m)
804      } else {
805        void call($, m, 'POST', '/api/module/ended', { cwd: m.cwd, session: m.session, agentId: e.agentId, answer: e.answer, reason: e.reason, ...refusal })
806      }
807    }
808    return r
809  })
810
811  on('session.end', async ($, e, next) => {
812    const r = await next(e)
813    if (!m.active) return r
814    if (e.reason === 'clear' || e.reason === 'resume') void (m.file ? followFile($, m, e.sessionId) : follow($, m, e.sessionId))
815    else {
816      stop(m)
817      if (m.file) {
818        m.out.gone = true
819        await writeOut($, m)
820      }
821    }
822    return r
823  })
824}
825