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…

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!
https://github.com/user-attachments/assets/3c21e405-6b8d-4ba6-85a8-24211800596c
claude "install thimble from https://github.com/safety-research/thimble"
Instructions for agents installing thimble on the user's behalf are in CLAUDE.md.
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.
thimble in a directory, just as you would run claude thimble --continue picks up your last conversation in this folder instead.thimble -c and a message in the agent's thread continue one, and Retry starts a view build, review or check again./thimble:orient and Claude's start tools take a model and an effort for one run.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
/thimbleagain after/clear(in a sessionthimblestarted, you quit and runthimble -cinstead, since thimble's sandbox lets out only the/thimbleof the session it started), and/thimbleprints 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 withdisableAllHooksorallowManagedHooksOnly, 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.
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).

cd <directory you want to analyze>
thimble mode terminal
thimble
Inside a Claude Code session
| Command | What it does | |||
|---|---|---|---|---|
/thimble | start the thimble server and print the dashboard URL | |||
/thimble fresh | archive this workspace and open an empty one | |||
/thimble restore [<name>] | bring an archived workspace back; with no name, list them | |||
/thimble status | one line: server, orientation, queue | |||
/thimble fix | in a development install, repair a server that will not start | |||
/thimble feedback | write 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)
| Command | What it does | |||
|---|---|---|---|---|
thimble | start 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 list | list 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 doctor | what 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 update | update to the latest release | |||
thimble uninstall | uninstall 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.
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.
Apache-2.0; see LICENSE.
hooks/thimble.ts 825 lines1// 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