A lead chat creates real Claude Code sessions on chosen models, watches them live and steers them.

Let Claude use Codex’s computer controls. Give one chat a team of real Claude sessions.
This is the source behind the two mods in the video. Take the working pieces, understand how they fit together, and adapt them to your setup. The complete build prompts are included, so you can also ask Claude to rebuild or extend them.
Start here · Quick-start PDF · Install · Try the demos · How it works · Troubleshooting
| Computer Use | Threads |
|---|---|
| Claude decides what to do. Codex’s local controls click and type in your Mac apps. | Your lead chat starts separate Claude sessions on the models you choose. |
| Per-app approval panel, saved choices, on/off command. | Live status, messages, returned answers, sequential plans and separate project copies. |
| Start with Calculator → TextEdit. | Start with two read-only helpers in a scratch project. |
| Build prompt · Source | Build prompt · Source |
I want to understand it. Read How it works. No installation needed. The interactive prompt breakdown is another way to explore the build prompts.
I want the existing mods. Check the requirements below, download or clone this repository, then follow Install. The included setup script checks your machine before it changes anything.
I want to build my own version. Give Claude one of the complete prompts. Ask it to inspect the tools your installation actually provides, build one piece at a time, and show each test. These prompts capture the lessons from an iterative build, not a guarantee of a one-shot result.
This is an unofficial, experimental integration, captured on 5 October 2026. It needs the mod-enabled Claude Code runtime, including the claude-code hook API. A regular plugin installation without that API is not enough.
tmux, a signed-in Claude terminal session and a trusted working folder. Desktop sidebar access depends on Remote Control availability for your account.No extra API key is required by this code. Your existing account access, usage limits and provider terms still apply. The cost shown by Threads is an API-equivalent estimate, not your bill.
From a permanent copy of this repository:
python3 scripts/setup.py
That only checks prerequisites and prints the planned commands. If the checks pass, read the installation notes, then run:
python3 scripts/setup.py --apply
Open a new Claude Code chat afterward. In that chat:
/codex-cu status
/threads setup
Then use the exact Calculator → TextEdit prompt or the two-helper prompt. The full demo checklist tells you what success looks like.
Permissions are part of the setup. Computer Use starts with no shared app approvals and auto-approve off. Threads preserves the filmed
bypassPermissionsdefault for new sessions. For a more restrictive mode, run/threads mode defaultbefore starting helpers. Use a disposable project for your first run.
plugins/
codex-computer-use/ The routing tool, commands and approval panel
threads/ The lead chat, sessions, panel, plans and tests
bridge/
launch.mjs Starts the installed Codex computer-use server
daemon.mjs Keeps connections open and handles app approvals
prompts/ Complete build prompts and copy-ready demos
scripts/setup.py Read-only checks, then explicit installation
examples/ A small project for the Threads demo
Your login, app approvals, chat history and personal files do not come with this repository. The setup creates new empty approval settings on your machine. See privacy and permissions for what the mods read and save once running.
| Command | Job |
|---|---|
/codex-cu on / /codex-cu off | Change the desktop computer-use route |
/codex-cu status | Inspect the current route and approvals |
/codex-cu auto on / /codex-cu auto off | Enable or disable automatic app approval |
/codex-cu forget all | Clear saved always-allowed apps; use auto off separately |
/threads | Open the team panel |
/threads setup | Check login, tmux and the current folder |
/threads mode default | Require normal permissions in new helper sessions |
/threads cap 4 | Set the live-helper limit |
/threads help | See the full command set |
You can also ask in ordinary language: “Start a Haiku helper to check the README,” “Tell the reviewer to focus on user-facing bugs,” or “Wait for both and bring their answers here.”
flowchart LR
A[You ask Claude] --> B[Claude chooses an action]
B --> C[Mod passes it to the local helper]
C --> D[Codex controls click or type]
D --> E[Claude reads the result]
E --> B
flowchart TB
L[Your lead chat] --> R[Reviewer: its own Claude session]
L --> C[Checker: its own Claude session]
R --> O[Answers return to the lead]
C --> O
The mod does not replace Claude’s reasoning with another model. The computer controls are exposed through MCP, a standard way to connect AI apps to tools. Threads uses Claude’s CLI to start sessions, tmux to keep them running, and records and messages to track and steer them. Read the plain-English walkthrough →
The exported mods passed 102 Threads tests and 7 Computer Use tests in the installed mod test runner. These use simulated engine behavior; they are not proof of a fresh end-to-end desktop installation. The setup helper has separate tests for empty approvals, preservation of existing files, paths with spaces and component selection.
The final video transcript records a working Calculator/TextEdit demo and Threads sessions appearing in the panel. A new viewer’s installation still needs the live acceptance checks. Exact verification scope and packaging changes are recorded in Verification.
claude plugin test plugins/threads
claude plugin test plugins/codex-computer-use
python3 -m unittest discover -s tests
node --test tests/bridge.test.mjs
There is no standalone npm install step for the mod API. It is supplied by the compatible Claude runtime. Do not download an unrelated package with a similar name to satisfy these imports.
Built with Claude Code; packaged and documented with Codex. Source is provided under the MIT License. Claude, Codex and ChatGPT belong to their respective providers. This repository is not affiliated with or endorsed by Anthropic or OpenAI.
hooks/threads.mjs 3772 lines1// Threads: a lead chat creates real Claude Code sessions ("threads") on chosen
2// models, watches them live and steers them.
3//
4// A thread is an interactive `claude` process in a private tmux server
5// (`tmux -L cc-threads`), so it needs no visible terminal. It registers itself in
6// <config>/sessions/<pid>.json under the session id we chose, writes its
7// transcript to <config>/projects/<slug(cwd)>/<sessionId>.jsonl, and listens on a
8// messaging socket, so the lead can:
9// - watch: tmux capture-pane (the live screen) and the transcript tail,
10// - steer: $.session.send({ to: { sessionId } }) (a message, works mid-turn),
11// or tmux send-keys (typing, Escape to interrupt, /model, permission keys),
12// - hear back: the thread's brief asks it to SendMessage a short report to the
13// lead's socket; `session.receive` records it and lets it through to the model.
14//
15// Every thread is in <config>/threads/registry.json (shared by every chat on the
16// machine) and every action in <config>/threads/events.jsonl.
17
18import { update } from "claude-code";
19import {
20 ATTENTION,
21 DEFAULT_CAP,
22 LIVE,
23 LOGIN_HINT,
24 MAX_CAP,
25 MODEL_ALIASES,
26 NEW_USAGE,
27 PANE_FORMAT,
28 age,
29 bandText,
30 buildBrief,
31 buildSpawnArgv,
32 checkMode,
33 cleanable,
34 clip,
35 colorOf,
36 dotOf,
37
38 isTrusted,
39 itemLine,
40
41 modelChip,
42 normalizeModel,
43 oneLine,
44 parseNew,
45 parsePanes,
46 parseRegistry,
47 parseTranscript,
48 readScreen,
49 remoteLink,
50 resolveRef,
51 resumeCommand,
52 screenLines,
53 serializeRegistry,
54 shortId,
55 shortPath,
56 shortTitle,
57 slug,
58 statusOf,
59 stripTags,
60 threadOfDelivery,
61 threadTitle,
62 tmuxArgv,
63 tmuxName,
64 tokenize,
65 activityLine,
66 agentName,
67 buildInlinePrompt,
68 chooseBackend,
69 messagesToItems,
70 pushBounded,
71 statusOfInline,
72 toolLine,
73 DEFAULT_MODE,
74 ANSWER_MAX,
75 heldMatches,
76 reportRow,
77 shouldReport,
78 EFFORTS,
79 buildPhasePrompt,
80 checkEffort,
81 gateAfter,
82 handoffNudge,
83 handoffPathFor,
84 parsePlanCommand,
85 phaseNumber,
86 phaseTitle,
87 planStatusText,
88 revisionPrompt,
89 stepPlan,
90 validatePlan,
91 costFromTranscript,
92 costOfUsage,
93 money,
94 planRecord,
95 planRecordMarkdown,
96 conversationOutline,
97 forkPrompt,
98 localTime,
99 worktreeFor,
100 worktreeOutcome,
101 isUnread,
102} from "./core.mjs";
103
104const PANE = "threads";
105const PANE_TITLE = "Threads";
106const VIEW = { plugin: "threads", key: "view" };
107const UI = { plugin: "threads", key: "ui" };
108const DETAIL = { plugin: "threads", key: "detail" };
109const AUTH = { plugin: "threads", key: "auth" };
110// inline threads: agentId -> thread id, per-thread activity feed, per-agent live facts
111const INLINE = { plugin: "threads", key: "inline" };
112const ACTIVITY = { plugin: "threads", key: "activity" };
113const AGENTS = { plugin: "threads", key: "agents" };
114// the completion watcher: last status it saw per thread, and messages the lead sent (for held ones)
115const WATCH = { plugin: "threads", key: "watch" };
116const SENT = { plugin: "threads", key: "sent" };
117// wakes waiting for the lead to be idle, and whether the lead's own turn is running
118const WAKES = { plugin: "threads", key: "wakes" };
119const LEAD = { plugin: "threads", key: "lead" };
120const WATCH_MS = 4000;
121const AUTOWAKE_KEY = "autowake";
122
123const EMPTY_VIEW = { threads: [], plans: [], selfId: "", leadTitle: "", refreshedAt: 0, cap: DEFAULT_CAP };
124const EMPTY_UI = { selected: "", mode: "transcript", showOthers: false, steering: false, notice: "", planView: false };
125const EMPTY_DETAIL = { id: "", mode: "transcript", lines: [], at: 0 };
126
127const AUTH_TTL_MS = 120000;
128const TICK_MS = 2000;
129const FAST_MS = 2000;
130const SLOW_MS = 10000;
131const CLEAN_AGE_MS = 7 * 24 * 3600 * 1000;
132const TAIL_BYTES = "262144";
133
134const T_CREATE = "threads_create";
135const T_LIST = "threads_list";
136const T_READ = "threads_read";
137const T_SEND = "threads_send";
138const T_WAIT = "threads_wait";
139const T_CLOSE = "threads_close";
140const T_PLAN = "threads_plan";
141const T_SETUP = "threads_setup";
142const SETUP_KEY = "setupPassedAt";
143const SETUP_RAN = { plugin: "threads", key: "setupRan" };
144const COST = { plugin: "threads", key: "cost" };
145// where the lead chat is shown: tool results carry no surface, so it is worked out once per session
146const SURFACE = { plugin: "threads", key: "surface" };
147const T_SHOW = "threads_show";
148const T_OPEN = "threads_open";
149const T_FORK = "threads_fork";
150const T_ADOPT = "threads_adopt";
151const T_HISTORY = "threads_plan_history";
152const T_PIN = "threads_pin";
153const T_ARCHIVE = "threads_archive";
154const T_ADVANCE = "threads_plan_advance";
155const T_RENAME = "threads_rename";
156const T_HANDOFF = "threads_handoff";
157const T_MARK_READ = "threads_mark_read";
158// inline threads being spawned: agent name -> { id, effort }, so the very first request already gets its effort
159const pendingInline = new Map();
160
161// Module state: a timer handle and an in-flight guard. A hot reload resets them.
162let ticker = null;
163let inflight = null;
164let dirReady = false;
165let watcher = null;
166let watching = false;
167// plan phases being started right now (a start can take seconds; never start one twice)
168const startingPhases = new Set();
169// tool_use_id -> { agentId, tool } of an inline thread's call in flight (to tie tool.check to it)
170const callsInFlight = new Map();
171
172// the configured default permission mode (userConfig), refreshed by each register
173let configuredMode = DEFAULT_MODE;
174// idleCloseMinutes setting: finished session threads idle this long are closed (0: never)
175let idleCloseMinutes = 120;
176let lastIdleCheck = 0;
177const MODE_KEY = "defaultMode";
178
179export function register(on, options) {
180 configuredMode = checkMode(options?.defaultPermissionMode).mode ?? DEFAULT_MODE;
181 const idle = Number(options?.idleCloseMinutes);
182 idleCloseMinutes = Number.isFinite(idle) && idle >= 0 ? idle : 120;
183 on("session.start", async ($, e, next) => {
184 const started = await next(e);
185 try {
186 await detectSurface($, e.surface);
187 } catch {
188 // keep the default
189 }
190 await $.command.register({
191 name: "threads",
192 description: "Threads: create, watch and steer Claude Code worker sessions on other models",
193 argumentHint: "[new <model> <title> [--inline|--session] -- <task> | list | send <id> <msg> | type | interrupt | model | approve | deny | open | close <id> | cap <n> | clean | help]",
194 immediate: true,
195 });
196 await registerTools($);
197 startTicker($);
198 try {
199 await refresh($);
200 } catch (err) {
201 $.ui.log(`threads: first refresh failed (${String(err?.message ?? err)})`, { to: "debug" });
202 }
203 await startWatcher($);
204 try {
205 await backfillPlanHistory($);
206 } catch (err) {
207 $.ui.log(`threads: plan history backfill failed (${String(err?.message ?? err)})`, { to: "debug" });
208 }
209 try {
210 await noticeOrphans($);
211 } catch (err) {
212 $.ui.log(`threads: orphan check failed (${String(err?.message ?? err)})`, { to: "debug" });
213 }
214 return started;
215 });
216
217 on("command.run", { command: "threads" }, async ($, e) => {
218 startTicker($);
219 return { text: await runCommand($, e.args ?? "") };
220 });
221
222 // ---- model tools -----------------------------------------------------------------
223
224 on("tool.call", { tool: "mcp__threads__threads_create" }, async ($, e) => {
225 startTicker($);
226 const made = await createThread($, {
227 model: e.model,
228 title: e.title,
229 task: e.task,
230 cwd: e.cwd,
231 permissionMode: e.permission_mode,
232 reportBack: e.report_back !== false,
233 backend: e.backend,
234 effort: e.effort,
235 worktree: e.worktree === true,
236 });
237 if (made.error) return { deny: made.error };
238 return { result: made.text };
239 });
240
241 on("tool.call", { tool: "mcp__threads__threads_list" }, async ($, e) => {
242 await refresh($);
243 return { result: await listText($, { forModel: true, includeArchived: e?.include_archived === true, includeClosed: e?.include_closed === true }) };
244 });
245
246 on("tool.call", { tool: "mcp__threads__threads_read" }, async ($, e) => {
247 await refresh($);
248 const found = await findThread($, e.id);
249 if (found.error) return { deny: found.error };
250 const text = await readText($, found.thread, e.view === "screen" ? "screen" : "transcript", e.limit);
251 if (isUnread(found.thread)) await markRead($, [found.thread.id]);
252 return { result: text };
253 });
254
255 on("tool.call", { tool: "mcp__threads__threads_send" }, async ($, e) => {
256 await refresh($);
257 const found = await findThread($, e.id);
258 if (found.error) return { deny: found.error };
259 const mode = e.mode ?? "message";
260 if (mode === "interrupt") return { result: await interruptThread($, found.thread) };
261 const message = String(e.message ?? "").trim();
262 if (!message) return { deny: "message is empty." };
263 if (mode === "type") return { result: await typeIntoThread($, found.thread, message) };
264 if (mode !== "message") return { deny: `Unknown mode "${mode}". Use message, type or interrupt.` };
265 return { result: await sendToThread($, found.thread, message) };
266 });
267
268 on("tool.call", { tool: "mcp__threads__threads_wait" }, async ($, e, next) => {
269 return { result: await waitForThreads($, e, next?.signal) };
270 });
271
272 on("tool.call", { tool: "mcp__threads__threads_close" }, async ($, e) => {
273 await refresh($);
274 const confirmed = e.confirmed === true;
275 if (e.all_of_plan) {
276 const p = await paths($);
277 const plan = await pickPlan($, p, String(e.all_of_plan));
278 if (!plan) return { deny: `No plan ${e.all_of_plan}.` };
279 return { result: await closePlanThreads($, plan, { confirmed, from: "tool" }) };
280 }
281 if (!e.id) return { deny: "threads_close needs id (a thread id such as t1a2b3, or the start of its title) or all_of_plan (a plan id). threads_list shows them." };
282 const found = await findThread($, e.id);
283 if (found.error) return { deny: found.error };
284 return { result: await closeThread($, found.thread, { confirmed, from: "tool" }) };
285 });
286
287 on("tool.call", { tool: "mcp__threads__threads_show" }, async ($, e) => {
288 startTicker($);
289 return { result: await showPane($, { id: e.id, plan: e.plan }) };
290 });
291
292 on("tool.call", { tool: "mcp__threads__threads_open" }, async ($, e) => {
293 await refresh($);
294 const found = await findThread($, e.id);
295 if (found.error) return { deny: found.error };
296 return { result: await openThread($, found.thread) };
297 });
298
299 on("tool.call", { tool: "mcp__threads__threads_fork" }, async ($, e) => {
300 startTicker($);
301 const made = await forkThread($, e);
302 if (made.error) return { deny: made.error };
303 return { result: made.text };
304 });
305
306 on("tool.call", { tool: "mcp__threads__threads_handoff" }, async ($, e) => {
307 startTicker($);
308 const made = await handoffThread($, e);
309 if (made.error) return { deny: made.error };
310 return { result: made.text };
311 });
312
313 on("tool.call", { tool: "mcp__threads__threads_rename" }, async ($, e) => {
314 await refresh($);
315 const found = await findThread($, e.id, { includeArchived: true });
316 if (found.error) return { deny: found.error };
317 return { result: await renameThread($, found.thread, e.title) };
318 });
319
320 on("tool.call", { tool: "mcp__threads__threads_mark_read" }, async ($, e) => {
321 return { result: await markReadText($, { id: e.id, all: e.all === true }) };
322 });
323
324 on("tool.call", { tool: "mcp__threads__threads_adopt" }, async ($, e) => {
325 return { result: await adoptThreads($, { ids: Array.isArray(e.ids) ? e.ids : [], all: e.all === true }) };
326 });
327
328 on("tool.call", { tool: "mcp__threads__threads_plan_history" }, async ($, e) => {
329 return { result: await planHistoryText($, e.planId) };
330 });
331
332 on("tool.call", { tool: "mcp__threads__threads_pin" }, async ($, e) => {
333 await refresh($);
334 const found = await findThread($, e.id);
335 if (found.error) return { deny: found.error };
336 return { result: await setFlag($, found.thread, "pinned", e.pinned !== false) };
337 });
338
339 on("tool.call", { tool: "mcp__threads__threads_archive" }, async ($, e) => {
340 await refresh($);
341 const found = await findThread($, e.id, { includeArchived: true });
342 if (found.error) return { deny: found.error };
343 return { result: await setFlag($, found.thread, "archived", e.archived !== false) };
344 });
345
346 on("tool.call", { tool: "mcp__threads__threads_setup" }, async ($, e) => {
347 const out = await runSetup($, { cwd: e.cwd, closeStale: e.close_stale === true });
348 return { result: out.text };
349 });
350
351 on("tool.call", { tool: "mcp__threads__threads_plan" }, async ($, e) => {
352 startTicker($);
353 const made = await createPlan($, e);
354 if (made.error) return { deny: made.error };
355 return { result: made.text };
356 });
357
358 on("tool.call", { tool: "mcp__threads__threads_plan_advance" }, async ($, e) => {
359 const out = await advancePlan($, e.planId, e.decision, e.feedback, { quiet: false });
360 if (out.error) return { deny: out.error };
361 return { result: out.text };
362 });
363
364 // ---- inline threads: what their loops do -----------------------------------------
365 //
366 // An inline thread is a background subagent of this chat. Its tool calls, model
367 // requests and turns pass through these hooks with `e.agentId`; only agents in
368 // the INLINE map are recorded, everything else passes straight through.
369
370 on("tool.call", async ($, e, next) => {
371 if (!e.agentId) return next(e);
372 const tid = await inlineThreadOf($, e.agentId);
373 if (!tid) return next(e);
374 const at = await $.clock.now();
375 await noteActivity($, tid, e.agentId, { kind: "tool", text: toolLine(e.tool, e), at }, { status: "working", lastAt: at });
376 const callKey = e.tool_use_id || `${e.agentId}:${e.tool}:${at}`;
377 callsInFlight.set(callKey, { agentId: e.agentId, tool: e.tool });
378 let ran;
379 try {
380 ran = await next(e);
381 } finally {
382 callsInFlight.delete(callKey);
383 }
384 const failed = ran?.deny !== undefined || ran?.isError === true;
385 const now = await $.clock.now();
386 if (failed) {
387 const why = ran?.deny ?? (typeof ran?.text === "string" ? ran.text : "error");
388 await noteActivity($, tid, e.agentId, { kind: "error", text: `${e.tool}: ${clip(oneLine(why), 200)}`, at: now }, { needsYou: false, lastAt: now });
389 } else {
390 await noteAgent($, e.agentId, { needsYou: false, lastAt: now });
391 }
392 return ran;
393 });
394
395 // A permission decision for one of an inline thread's calls: an "ask" means it waits on the person.
396 on("tool.check", async ($, e, next) => {
397 const decided = await next(e);
398 // by the call's id; a check without one is tied to the only inline call of that tool in flight
399 let agentId = e.tool_use_id ? callsInFlight.get(e.tool_use_id)?.agentId : undefined;
400 if (!agentId && !e.tool_use_id) {
401 const same = [...callsInFlight.values()].filter((c) => c.tool === e.tool);
402 if (same.length === 1) agentId = same[0].agentId;
403 }
404 if (!agentId || decided?.decision !== "ask") return decided;
405 const tid = await inlineThreadOf($, agentId);
406 if (tid) {
407 const at = await $.clock.now();
408 await noteActivity($, tid, agentId, { kind: "wait", text: `asks to run ${toolLine(e.tool, e.input)}`, at }, { needsYou: true, lastAt: at });
409 const { value: view = EMPTY_VIEW } = await $.state.get(VIEW);
410 const title = view.threads.find((x) => x.id === tid)?.title ?? tid;
411 $.ui.toast(`${title} needs you: asks to run ${clip(toolLine(e.tool, e.input), 80)}`, { timeoutMs: 8000 });
412 }
413 return decided;
414 });
415
416 on("turn.step", async function* ($, e, next) {
417 let tid = e.agentId ? await inlineThreadOf($, e.agentId) : undefined;
418 // the agent can make its first request before agent.spawn resolves: match it by name now
419 if (!tid && e.agentId && pendingInline.size) tid = await claimPendingInline($, e.agentId);
420 if (!tid) return yield* next(e);
421 const at = await $.clock.now();
422 const { value: metaAll = {} } = await $.state.get(AGENTS);
423 const effort = metaAll[e.agentId]?.effort;
424 await noteActivity($, tid, e.agentId, { kind: "step", text: `${e.model}${effort ? ` (${effort})` : ""} thinking (request ${e.index + 1})`, at }, { status: "working", model: e.model, lastAt: at, partial: "" });
425 // the thread's effort rides on each of its requests (agent.spawn takes none)
426 const stream = next(effort ? { ...e, effort } : e);
427 let partial = "";
428 let shownAt = at;
429 for await (const chunk of stream) {
430 if (chunk?.kind === "text" && typeof chunk.text === "string") {
431 partial += chunk.text;
432 const now = await $.clock.now();
433 if (now - shownAt > 1500) {
434 shownAt = now;
435 await noteAgent($, e.agentId, { partial: clip(oneLine(partial), 300), lastAt: now });
436 }
437 }
438 yield chunk;
439 }
440 const result = await stream.result;
441 const now = await $.clock.now();
442 const model = result?.usage?.model || e.model;
443 if (result?.usage) {
444 // a subagent's request: cache writes at the 5-minute rate unless the usage says otherwise
445 const c = costOfUsage(result.usage, model, "sub");
446 await update($, AGENTS, (all) => {
447 const cur = (all ?? {})[e.agentId] ?? {};
448 return { ...(all ?? {}), [e.agentId]: { ...cur, costUsd: (cur.costUsd ?? 0) + c.usd } };
449 });
450 }
451 if (result?.answer && result.answer.trim()) {
452 await noteActivity($, tid, e.agentId, { kind: "assistant", text: clip(oneLine(result.answer), 400), at: now }, { model, partial: "", lastAt: now });
453 } else {
454 await noteAgent($, e.agentId, { model, partial: "", lastAt: now });
455 }
456 return result;
457 });
458
459 // The end of an inline thread's run: its answer is the report.
460 // The lead's own turns: a wake is only submitted while the lead is idle, so it never queues
461 // behind a turn that may resolve the gate it is about.
462 on("turn.start", async ($, e, next) => {
463 if (!e.agentId) await $.state.set(LEAD, { busy: true });
464 return next(e);
465 });
466
467 on("prompt.submit", async ($, e, next) => {
468 // our own wake, checked once more as it enters: a gate resolved meanwhile drops it
469 if (e.origin?.kind === "plugin" && e.origin?.name === "threads") {
470 const gate = wakeGateOf(e.text);
471 if (gate && !(await gateStillOpen($, gate.planId, gate.index))) {
472 return { drop: `threads: phase ${phaseNumber(gate.index)} of plan ${gate.planId} was already decided` };
473 }
474 }
475 return next(e);
476 });
477
478 on("turn.complete", async ($, e, next) => {
479 const result = await next(e);
480 if (!e.agentId) {
481 await $.state.set(LEAD, { busy: false });
482 await scheduleWakes($);
483 return result;
484 }
485 try {
486 await noteInlineDone($, e);
487 } catch (err) {
488 $.ui.log(`threads: could not record an inline report (${String(err?.message ?? err)})`, { to: "debug" });
489 }
490 return result;
491 });
492
493 // ---- reports -----------------------------------------------------------------------
494
495 // A thread's report: note it on the thread, toast it, and let it through so the
496 // lead's model reads it. Anything that is not from a registered thread is untouched.
497 on("session.receive", async ($, e, next) => {
498 if (e.agentId !== undefined) return next(e);
499 const text = String(e.text ?? "");
500 if (!text.includes("<cross-session-message")) return next(e);
501 try {
502 await noteReport($, text);
503 } catch (err) {
504 $.ui.log(`threads: could not record a report (${String(err?.message ?? err)})`, { to: "debug" });
505 }
506 return next(e);
507 });
508
509 // ---- drawing -------------------------------------------------------------------------
510
511 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
512 const original = await next(e);
513 if (e.props.hasSurvey) return original;
514 const { value: view = EMPTY_VIEW } = await $.state.get(VIEW);
515 const mine = view.threads.filter((t) => t.isMine);
516 const gates = (view.plans ?? [])
517 .filter((pl) => pl.status === "running" && pl.phases[pl.current ?? 0]?.status === "awaiting-gate")
518 .map((pl) => {
519 const i = pl.current ?? 0;
520 return gateAfter(pl, i) === "user" ? `plan ${pl.title}: phase ${phaseNumber(i + 1)} ready · /threads plan next` : `plan ${pl.title}: phase ${phaseNumber(i)} awaits lead review`;
521 });
522 const text = [bandText(mine), ...gates].filter(Boolean).join(" · ");
523 if (!text) return original;
524 const { Box, Text } = $.ui.resolve(e);
525 const needs = mine.some((t) => ATTENTION.has(t.status)) || gates.length > 0;
526 const row = Box({
527 flexDirection: "row",
528 children: [Text({ color: needs ? "magenta" : "cyan", wrap: "truncate", children: clip(text, Math.max(20, e.props.bodyColumns ?? 100)) })],
529 });
530 if (!original) return row;
531 return Box({ flexDirection: "column", children: [original, row] });
532 });
533
534 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
535 const els = $.ui.resolve(e);
536 const { Box, Text, Button } = els;
537 const Input = els.Input;
538 const { value: view = EMPTY_VIEW } = await $.state.get(VIEW);
539 const { value: ui = EMPTY_UI } = await $.state.get(UI);
540 const { value: detail = EMPTY_DETAIL } = await $.state.get(DETAIL);
541 const width = Math.max(40, e.props.bodyColumns ?? 100);
542 const isWide = width >= 96;
543 const leftW = isWide ? Math.min(44, Math.floor(width * 0.36)) : width;
544 const rightW = isWide ? width - leftW - 2 : width;
545 const dim = (s, w) => Text({ dimColor: true, wrap: "truncate", children: clip(s, w) });
546
547 const visible = view.threads.filter((t) => ui.showArchived || !t.archived);
548 const archivedCount = view.threads.filter((t) => t.archived).length;
549 const pinFirst = (a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned));
550 const mine = visible.filter((t) => t.isMine).sort(pinFirst);
551 const others = visible.filter((t) => !t.isMine).sort(pinFirst);
552 const shown = [...mine, ...(ui.showOthers ? others : [])];
553 const selected = shown.find((t) => t.id === ui.selected) ?? shown[0] ?? null;
554 const isDesktop = e.surface !== "terminal";
555
556 // left: the tree
557 const tree = [
558 Text({ bold: true, wrap: "truncate", children: clip(`Lead · ${view.leadTitle || "this chat"}`, leftW) }),
559 ];
560 if (mine.length === 0) tree.push(dim(" no threads yet: /threads new", leftW));
561 const row = (t, i, indent) => {
562 const hotkey = i < 9 ? String(i + 1) : undefined;
563 const isSel = selected && t.id === selected.id;
564 return Box({
565 key: `row:${t.id}`,
566 flexDirection: "row",
567 gap: 1,
568 children: [
569 Text({ children: `${indent}${isSel ? "›" : " "}` }),
570 Text({ color: colorOf(t.status), children: dotOf(t.status) }),
571 Text({ color: "blue", children: modelChip(t.verifiedModel || t.requestedModel).padEnd(6) }),
572 Button({
573 key: `sel:${t.id}`,
574 label: clip(`${t.pinned ? "⚑ " : ""}${t.forkedFrom ? "⑂ " : ""}${t.worktree && !t.worktree.removed ? "⎇ " : ""}${t.desktop?.length ? "▣ " : ""}${shortTitle(t.title)}${t.archived ? " (archived)" : ""}`, Math.max(8, leftW - 24 - indent.length - (isUnread(t) ? 4 : 0))),
575 hotkey,
576 plain: true,
577 dimColor: !isSel,
578 onPress: () => selectThread($, t.id),
579 }),
580 ...(isUnread(t) ? [Text({ color: "yellow", bold: true, children: "new" })] : []),
581 ...(t.costUsd ? [Text({ dimColor: true, children: money(t.costUsd) })] : []),
582 ],
583 });
584 };
585 mine.forEach((t, i) => tree.push(row(t, i, " ")));
586 if (others.length > 0) {
587 tree.push(
588 Button({
589 key: "others",
590 label: `${ui.showOthers ? "Hide" : "Show"} other leads' threads (${others.length})`,
591 hotkey: "a",
592 plain: true,
593 dimColor: true,
594 onPress: () => patchUi($, (u) => ({ ...u, showOthers: !u.showOthers })),
595 }),
596 );
597 if (ui.showOthers) {
598 let lastParent = "";
599 others.forEach((t, j) => {
600 const parent = t.parent?.title || t.parent?.sessionId?.slice(0, 8) || "unknown lead";
601 if (parent !== lastParent) {
602 tree.push(dim(`Lead · ${parent}`, leftW));
603 lastParent = parent;
604 }
605 tree.push(row(t, mine.length + j, " "));
606 });
607 }
608 }
609 const totalCost = mine.reduce((a, t) => a + (t.costUsd ?? 0), 0);
610 tree.push(dim(`${view.threads.filter((t) => LIVE.has(t.status)).length} live · cap ${view.cap}${totalCost ? ` · ${money(totalCost)} est.` : ""}`, leftW));
611 if (archivedCount) {
612 tree.push(
613 Button({
614 key: "archived",
615 label: `${ui.showArchived ? "Hide" : "Show"} archived (${archivedCount})`,
616 hotkey: "h",
617 plain: true,
618 dimColor: true,
619 onPress: () => patchUi($, (u) => ({ ...u, showArchived: !u.showArchived })),
620 }),
621 );
622 }
623
624 // right: the selected thread, or its plan
625 const right = [];
626 const plans = view.plans ?? [];
627 const planAt = Math.min(Math.max(0, plans.length - 1 - (ui.planIndex ?? 0)), Math.max(0, plans.length - 1));
628 const plan = ui.planView ? (ui.planIndex ? plans[planAt] : plans.find((x) => x.id === selected?.planId) ?? plans[planAt]) : null;
629 if (plan) {
630 const byId = new Map(view.threads.map((x) => [x.id, x]));
631 const color = { working: "yellow", "awaiting-gate": "magenta", revising: "cyan", blocked: "red", done: "green", accepted: "green", queued: undefined };
632 const planCost = plan.phases.reduce((a, ph) => a + (byId.get(ph.threadId)?.costUsd ?? 0), 0);
633 const isHistory = plan.status !== "running";
634 right.push(Text({ bold: true, wrap: "truncate", children: clip(`Plan ${plan.title} (${plan.id}) · ${plan.status} · gate ${plan.gate}${isHistory ? " · history (read-only)" : ""}`, rightW) }));
635 right.push(dim(`est. API-equivalent ${money(planCost)} · ${plan.phases.length} phases`, rightW));
636 if (plans.length > 1) {
637 right.push(
638 Box({
639 flexDirection: "row",
640 gap: 1,
641 children: [
642 Button({ key: "plan-older", label: "‹ Older plan", onPress: () => patchUi($, (u) => ({ ...u, planIndex: Math.min(plans.length - 1, (u.planIndex ?? 0) + 1) })) }),
643 Button({ key: "plan-newer", label: "Newer plan ›", onPress: () => patchUi($, (u) => ({ ...u, planIndex: Math.max(0, (u.planIndex ?? 0) - 1) })) }),
644 ],
645 }),
646 );
647 }
648 right.push(dim(`handoffs in ${shortPath(plan.handoffDir, rightW - 12)}`, rightW));
649 plan.phases.forEach((ph, i) => {
650 const th = byId.get(ph.threadId);
651 right.push(
652 Box({
653 flexDirection: "row",
654 gap: 1,
655 children: [
656 Text({ color: color[ph.status], children: `${phaseNumber(i)} ${ph.status.padEnd(13)}` }),
657 Text({ color: "blue", children: `${ph.model}${ph.effort ? `/${ph.effort}` : ""}` }),
658 Text({ wrap: "truncate", children: clip(`${ph.name}${th ? ` · ${th.id} ${th.status}` : ""}${th?.costUsd ? ` · ${money(th.costUsd)}` : ""}${(ph.gate ?? plan.gate) !== "auto" && i < plan.phases.length - 1 ? ` · gate ${ph.gate ?? plan.gate}` : ""}`, Math.max(10, rightW - 34)) }),
659 ],
660 }),
661 );
662 if (ph.handoffPath) right.push(dim(` handoff ${shortPath(ph.handoffPath, rightW - 11)}`, rightW));
663 if (ph.note) right.push(dim(` ${ph.note}`, rightW));
664 });
665 const chain = plan.phases.map((ph) => ph.threadId || "·").join(" → ");
666 right.push(dim(`threads ${chain}`, rightW));
667 if (plan.status === "running" && plan.phases[plan.current ?? 0]?.status === "awaiting-gate" && gateAfter(plan, plan.current ?? 0) === "user") {
668 right.push(Text({ color: "magenta", children: "Phase ready: run /threads plan next" }));
669 }
670 } else if (!selected) {
671 right.push(dim("Create one with /threads new haiku Scout -- list the files here", rightW));
672 right.push(dim("or ask in plain English: spin up a Haiku thread to ...", rightW));
673 } else {
674 const t = selected;
675 const verified = t.verifiedModel ? t.verifiedModel : "not seen yet";
676 right.push(Text({ bold: true, wrap: "truncate", children: clip(`${t.title} (${t.id})`, rightW) }));
677 right.push(
678 Box({
679 flexDirection: "row",
680 gap: 1,
681 children: [
682 Text({ color: colorOf(t.status), children: `${dotOf(t.status)} ${t.status}` }),
683 dim(`· ${age((view.refreshedAt || 0) - (t.createdAt || 0))} old · ${t.permissionMode}`, rightW - 16),
684 ],
685 }),
686 );
687 right.push(dim(`model asked ${t.requestedModel} · running ${verified}${t.effort ? ` · effort ${t.effort}` : ""}`, rightW));
688 const isInline = t.backend === "inline";
689 right.push(dim(`cwd ${shortPath(t.cwd, isInline ? 40 : rightW - 7)}${isInline ? ` · inline agent ${t.agentId}` : ""}`, rightW));
690 right.push(dim(`task ${oneLine(t.task)}`, rightW));
691 right.push(dim(`cost ${t.costUsd ? money(t.costUsd) : "$0"} est. API-equivalent${t.pinned ? " · pinned" : ""}${t.archived ? " · archived" : ""}`, rightW));
692 if (t.forkedFrom) right.push(dim(`${t.handedOff ? "handoff" : "fork "} from "${t.forkedFrom.title}" with ${t.forkedFrom.include === "full" ? "its transcript" : "a summary"}`, rightW));
693 if (t.desktop?.length) right.push(dim(`desk drives ${t.desktop.join(", ")} through Codex computer use (leased to this thread)`, rightW));
694 if (t.worktree) {
695 const w = t.worktree;
696 const state = w.removed ? "removed (no changes)" : w.kept ? `kept${w.commits ? `, ${w.commits} commit${w.commits === 1 ? "" : "s"}` : ""}${w.dirty ? ", uncommitted changes" : ""} · merge: git merge ${w.branch}` : "isolated";
697 right.push(dim(`tree ${w.branch} · ${state}`, rightW));
698 }
699 if (t.lastReport?.text) {
700 right.push(Text({ color: "cyan", wrap: "truncate", children: clip(`report ${t.lastReport.text}`, rightW) }));
701 }
702 if (t.status === "needs-you" && t.prompt && isInline) {
703 right.push(Text({ color: "magenta", wrap: "truncate", children: clip(`waiting: ${oneLine(t.prompt)} (answer it in this chat)`, rightW) }));
704 } else if (t.status === "needs-you" && t.prompt) {
705 right.push(Text({ color: "magenta", wrap: "truncate", children: clip(`waiting: ${oneLine(t.prompt)}`, rightW) }));
706 }
707 if (t.status === "needs-login") right.push(Text({ color: "red", wrap: "truncate", children: clip(LOGIN_HINT, rightW) }));
708 if (t.status === "needs-trust") {
709 right.push(Text({ color: "red", wrap: "truncate", children: clip("Sits on the folder trust prompt. Trust the folder yourself, then close and recreate.", rightW) }));
710 }
711 const isLive = LIVE.has(t.status);
712 const buttons = [
713 Button({ key: "view", label: `w ${ui.mode === "screen" ? "Transcript" : isInline ? "Activity" : "Screen"}`, hotkey: "w", onPress: () => toggleMode($) }),
714 ];
715 if (isLive) {
716 buttons.push(
717 Button({ key: "steer", label: "s Steer", hotkey: "s", variant: "primary", onPress: () => patchUi($, (u) => ({ ...u, steering: !u.steering })) }),
718 Button({ key: "interrupt", label: isInline ? "i Stop" : "i Interrupt", hotkey: "i", onPress: () => noticeOf($, () => interruptThread($, t)) }),
719 );
720 // model and effort open a row of choices right here (no dialog, so they work on every surface)
721 if (!isInline) buttons.push(Button({ key: "model", label: "m Model", hotkey: "m", onPress: () => patchUi($, (u) => ({ ...u, picker: u.picker === "model" ? "" : "model" })) }));
722 buttons.push(Button({ key: "effort", label: "e Effort", hotkey: "e", onPress: () => patchUi($, (u) => ({ ...u, picker: u.picker === "effort" ? "" : "effort" })) }));
723 }
724 if (t.status === "needs-you" && !isInline) {
725 buttons.push(
726 Button({ key: "approve", label: "y Approve", hotkey: "y", onPress: () => noticeOf($, () => answerPrompt($, t, true)) }),
727 Button({ key: "deny", label: "n Deny", hotkey: "n", onPress: () => noticeOf($, () => answerPrompt($, t, false)) }),
728 );
729 }
730 buttons.push(
731 Button({ key: "open", label: "o Open", hotkey: "o", onPress: (pe) => noticeOf($, () => openThread($, t, pe?.surface)) }),
732 );
733 buttons.push(
734 Button({ key: "pin", label: t.pinned ? "u Unpin" : "u Pin", hotkey: "u", onPress: () => noticeOf($, () => setFlag($, t, "pinned", !t.pinned)) }),
735 Button({ key: "archive", label: t.archived ? "v Unarchive" : "v Archive", hotkey: "v", onPress: () => noticeOf($, () => setFlag($, t, "archived", !t.archived)) }),
736 Button({ key: "rename", label: "t Rename", hotkey: "t", onPress: () => patchUi($, (u) => ({ ...u, renaming: !u.renaming, steering: false })) }),
737 );
738 if (t.status !== "closed") {
739 buttons.push(Button({ key: "close-thread", label: "x Close", hotkey: "x", onPress: () => noticeOf($, () => closeThread($, t, { from: "pane" })) }));
740 }
741 right.push(Box({ flexDirection: "row", flexWrap: "wrap", gap: 1, children: buttons }));
742 if (ui.picker && isLive) {
743 const choices = ui.picker === "model" ? MODEL_ALIASES : EFFORTS;
744 const current = ui.picker === "model" ? t.requestedModel : t.effort;
745 right.push(
746 Box({
747 flexDirection: "row",
748 flexWrap: "wrap",
749 gap: 1,
750 children: [
751 Text({ dimColor: true, children: ui.picker === "model" ? "model:" : "effort:" }),
752 ...choices.map((c) =>
753 Button({
754 key: `pick:${ui.picker}:${c}`,
755 label: c === current ? `${c} ✓` : c,
756 variant: c === current ? "primary" : undefined,
757 onPress: () =>
758 noticeOf($, async () => {
759 const kind = ui.picker;
760 await patchUi($, (u) => ({ ...u, picker: "" }));
761 return kind === "model" ? setThreadModel($, t, c) : setThreadEffort($, t, c);
762 }),
763 }),
764 ),
765 Button({ key: "pick:cancel", label: "Cancel", onPress: () => patchUi($, (u) => ({ ...u, picker: "" })) }),
766 ],
767 }),
768 );
769 }
770 if (ui.armedClose?.id === t.id) right.push(Text({ color: "magenta", wrap: "truncate", children: clip("Press Close again within 10 seconds to close it.", rightW) }));
771 if (ui.renaming && Input) {
772 right.push(
773 Input({
774 key: "rename-input",
775 label: "New name › ",
776 placeholder: shortTitle(t.title),
777 submitLabel: "rename",
778 autoFocus: true,
779 onSubmit: (value) =>
780 noticeOf($, async () => {
781 await patchUi($, (u) => ({ ...u, renaming: false }));
782 return String(value ?? "").trim() ? renameThread($, t, value) : "Not renamed.";
783 }),
784 }),
785 );
786 }
787 if (ui.steering && isLive) {
788 if (Input) {
789 right.push(
790 Input({
791 key: "steer-input",
792 label: "Message › ",
793 placeholder: "tell the thread what to change, Enter sends",
794 submitLabel: "send",
795 autoFocus: true,
796 onSubmit: (value) => noticeOf($, () => steerFromPane($, t, value)),
797 }),
798 );
799 } else {
800 right.push(dim(`Use /threads send ${t.id} <message> to steer it.`, rightW));
801 }
802 }
803 for (const line of String(ui.notice ?? "").split("\n").filter(Boolean).slice(0, 4)) {
804 right.push(Text({ color: "green", wrap: "truncate", children: clip(line, rightW) }));
805 }
806 const isFresh = detail.id === t.id && detail.mode === ui.mode;
807 const heading = ui.mode === "screen" ? (isInline ? "Live activity (last 30)" : "Screen (last 30 lines)") : "Transcript (last 20)";
808 right.push(Text({ bold: true, children: heading }));
809 if (!isFresh) right.push(dim("loading…", rightW));
810 else if (detail.lines.length === 0) {
811 right.push(dim(ui.mode === "screen" ? (isInline ? "(no activity seen yet)" : "(screen is empty or the thread is gone)") : "(nothing in the transcript yet)", rightW));
812 }
813 else for (const l of detail.lines) right.push(Text({ wrap: "truncate", dimColor: ui.mode === "screen", children: clip(l, rightW) }));
814 }
815
816 const head = Box({
817 flexDirection: "row",
818 gap: 1,
819 children: [
820 dim(`Threads · ${mine.length} yours${others.length ? ` · ${others.length} other` : ""} · ${clockOf(view.refreshedAt)}`, Math.max(20, width - 26)),
821 Button({ key: "refresh", label: "r Refresh", hotkey: "r", onPress: () => refresh($, { force: true }) }),
822 ...((view.plans ?? []).length
823 ? [Button({ key: "plan-view", label: ui.planView ? "p Threads" : "p Plan", hotkey: "p", onPress: () => patchUi($, (u) => ({ ...u, planView: !u.planView })) })]
824 : []),
825 Button({ key: "close-pane", label: "c Close", hotkey: "c", role: "dismiss", onPress: () => $.ui.close({ id: PANE }) }),
826 ],
827 });
828 // how to use the pane: clicking works everywhere; keys need the pane to hold the keyboard
829 const hint = Text({
830 dimColor: true,
831 wrap: "wrap",
832 children: isDesktop
833 ? "Click a thread or any button. You can also ask in the chat: show, steer, fork, wait for or close threads."
834 : "Click a thread or any button. Keys: ctrl+x then tab gives the pane the keyboard; the letter on each button is its key.",
835 });
836 const body = isWide
837 ? Box({
838 flexDirection: "row",
839 gap: 2,
840 children: [
841 Box({ flexDirection: "column", width: leftW, children: tree }),
842 Box({ flexDirection: "column", width: rightW, children: right }),
843 ],
844 })
845 : Box({ flexDirection: "column", children: [...tree, Text({ children: " " }), ...right] });
846 return Box({ flexDirection: "column", children: [head, hint, body] });
847 });
848}
849
850// ---- setup ----------------------------------------------------------------------------------
851
852async function registerTools($) {
853 const idProp = { type: "string", description: "The thread's id (like t3f9a2) or the start of its title" };
854 await $.tool.register({
855 name: T_CREATE,
856 description:
857 "Create a thread: a real, separate Claude Code session on a chosen model that works on a task in the background, " +
858 "which the user can watch (/threads) and you can steer and monitor. Use it when the user asks to spin up, start, or create " +
859 "a thread, worker or session on a model (\"spin up a Haiku thread to triage the inbox\"). One call per thread. " +
860 "By default the thread sends you a short report when it finishes or gets stuck; it reaches you as a cross-session message. " +
861 "Models: haiku, sonnet, opus, fable, or a full claude-* id. Session threads run with bypassed permissions by default (they never stop for approval); pass permission_mode to change one, or /threads mode for all.",
862 inputSchema: {
863 type: "object",
864 properties: {
865 model: { type: "string", description: "haiku, sonnet, opus, fable, or a full model id" },
866 title: { type: "string", description: "Short title, e.g. \"Haiku scout\"; shown as \"Thread | <title>\"" },
867 task: { type: "string", description: "The thread's task, written as a complete first prompt" },
868 cwd: { type: "string", description: "Folder to work in (default: this chat's folder). Must be a trusted folder." },
869 permission_mode: { type: "string", enum: ["bypassPermissions", "default", "acceptEdits", "plan", "auto"], description: "Permission mode for a session thread (default bypassPermissions, or what /threads mode set). Inline threads use this chat's mode." },
870 report_back: { type: "boolean", description: "Ask the thread to message a short report back to this chat (default true)" },
871 effort: { type: "string", enum: ["low", "medium", "high", "xhigh", "max"], description: "Reasoning effort (default: the model's)" },
872 worktree: { type: "boolean", description: "Run it in its own git worktree and branch (the folder must be in a git repo), so parallel threads never edit the same files. Use when the user asks for isolation, a worktree or a separate branch, or when several threads will edit the same repo at once" },
873 backend: {
874 type: "string",
875 enum: ["auto", "session", "inline"],
876 description:
877 "session: its own Claude Code process (needs the terminal CLI login; shows in the sidebar via Remote Control). inline: a background agent of this chat on this chat's login. auto (default): session when the CLI is logged in, else inline.",
878 },
879 },
880 required: ["model", "title", "task"],
881 },
882 });
883 await $.tool.register({
884 name: T_LIST,
885 description: "List the threads (Claude Code worker sessions) with their status, model, permission mode, est. API-equivalent cost, folder and latest output. Archived threads, and other chats' closed threads, are left out unless include_archived or include_closed.",
886 inputSchema: { type: "object", properties: { include_archived: { type: "boolean" }, include_closed: { type: "boolean", description: "Also list other chats' closed threads" } } },
887 });
888 await $.tool.register({
889 name: T_READ,
890 description: "Read what a thread is doing: its transcript (prompts, replies, tool calls, errors) or its live terminal screen.",
891 inputSchema: {
892 type: "object",
893 properties: {
894 id: idProp,
895 view: { type: "string", enum: ["transcript", "screen"], description: "transcript (default) or screen" },
896 limit: { type: "number", description: "Items (transcript, default 20, max 60) or lines (screen, default 30, max 80)" },
897 },
898 required: ["id"],
899 },
900 });
901 await $.tool.register({
902 name: T_SEND,
903 description:
904 "Steer a thread. mode message (default) delivers a message the thread reads even mid-task; mode type types the text " +
905 "into its prompt and presses Enter (use for slash commands); mode interrupt presses Escape to stop its current step.",
906 inputSchema: {
907 type: "object",
908 properties: {
909 id: idProp,
910 message: { type: "string", description: "What to send or type (ignored for interrupt)" },
911 mode: { type: "string", enum: ["message", "type", "interrupt"] },
912 },
913 required: ["id"],
914 },
915 });
916 await $.tool.register({
917 name: T_WAIT,
918 description:
919 "Wait for threads and return what changed plus their latest output. until idle (default) waits until every named " +
920 "thread has finished its turn or needs the user; any_change returns at the first status or output change; needs_you " +
921 "returns when one needs the user. Polls every 3 seconds, at most timeout_s (default 300, max 600).",
922 inputSchema: {
923 type: "object",
924 properties: {
925 ids: { type: "array", items: { type: "string" }, description: "Thread ids or title prefixes; default: this chat's live threads" },
926 until: { type: "string", enum: ["idle", "any_change", "needs_you"] },
927 timeout_s: { type: "number", description: "Seconds, at most 600" },
928 },
929 },
930 });
931 await $.tool.register({
932 name: T_PLAN,
933 description:
934 "Run a phase plan: phases run strictly one at a time, each as its own thread on its own model, and each ends by writing a " +
935 "handoff file the next phase starts from. Use when the user wants a multi-step job split into phases on different models " +
936 "(\"architect on opus, build on sonnet, test on haiku\"). gate decides who lets the next phase start: auto (at once), lead " +
937 "(you review each phase and call threads_plan_advance; default) or user (/threads plan next). A phase's own gate overrides " +
938 "the plan's for the step after it.",
939 inputSchema: {
940 type: "object",
941 properties: {
942 title: { type: "string", description: "Short plan title" },
943 cwd: { type: "string", description: "Project folder (default: this chat's); must be trusted for session threads" },
944 gate: { type: "string", enum: ["auto", "lead", "user"] },
945 handoff_dir: { type: "string", description: "Where handoff files go (default <cwd>/handoff)" },
946 backend: { type: "string", enum: ["auto", "session", "inline"], description: "Default auto: session when the terminal CLI is logged in" },
947 keep_threads: { type: "boolean", description: "Keep every phase thread open until you close it (default false: an accepted phase's thread closes once the next phase is under way)" },
948 phases: {
949 type: "array",
950 items: {
951 type: "object",
952 properties: {
953 name: { type: "string" },
954 model: { type: "string", description: "haiku, sonnet, opus, fable or a full id" },
955 effort: { type: "string", enum: ["low", "medium", "high", "xhigh", "max"] },
956 task: { type: "string", description: "The phase's task, complete" },
957 acceptance: { type: "string", description: "How to check the phase is done" },
958 permission_mode: { type: "string", enum: ["bypassPermissions", "default", "acceptEdits", "plan", "auto"], description: "Default bypassPermissions (or what /threads mode set)" },
959 gate: { type: "string", enum: ["auto", "lead", "user"], description: "Gate after this phase (default: the plan's)" },
960 },
961 required: ["name", "model", "task"],
962 },
963 },
964 },
965 required: ["title", "phases"],
966 },
967 });
968 await $.tool.register({
969 name: T_ADVANCE,
970 description:
971 "Decide on a plan phase that waits for your review (a lead gate): approve starts the next phase; revise sends your feedback " +
972 "to the same phase thread, which revises and hands off again. After two revisions a further revise marks the phase blocked.",
973 inputSchema: {
974 type: "object",
975 properties: {
976 planId: { type: "string" },
977 decision: { type: "string", enum: ["approve", "revise"] },
978 feedback: { type: "string", description: "What to change (for revise)" },
979 },
980 required: ["planId", "decision"],
981 },
982 });
983 await $.tool.register({
984 name: T_SHOW,
985 description:
986 "Open the Threads pane next to this chat (the user sees every thread, its status, model, cost and live output, with buttons). Use it when the user asks to see, show or watch the threads, a thread or a plan. Optional id selects a thread; plan: true opens the plan view.",
987 inputSchema: { type: "object", properties: { id: idProp, plan: { type: "boolean", description: "Open the plan view" } } },
988 });
989 await $.tool.register({
990 name: T_OPEN,
991 description: "Open a session thread as its own chat in the Claude desktop app (through Remote Control), for when the user wants to look at it or talk to it directly.",
992 inputSchema: { type: "object", properties: { id: idProp }, required: ["id"] },
993 });
994 await $.tool.register({
995 name: T_FORK,
996 description:
997 "Fork this chat: start a thread that already knows what this conversation established, then works on a new task (like forking a chat). include summary (default) passes a compact summary of goal, decisions, files and state; full gives it this conversation's transcript as a file to read. Use when the user says fork, branch off, or continue this in a new thread.",
998 inputSchema: {
999 type: "object",
1000 properties: {
1001 title: { type: "string" },
1002 task: { type: "string", description: "What the fork should do next" },
1003 model: { type: "string", description: "Default: this chat's model. Pass one only when the user names it: a fork reasons over a dense summary, and haiku misreads details more often than sonnet or opus" },
1004 effort: { type: "string", enum: ["low", "medium", "high", "xhigh", "max"] },
1005 cwd: { type: "string" },
1006 include: { type: "string", enum: ["summary", "full"] },
1007 backend: { type: "string", enum: ["auto", "session", "inline"] },
1008 worktree: { type: "boolean", description: "Run it in its own git worktree and branch (the folder must be in a git repo), so parallel threads never edit the same files. Use when the user asks for isolation, a worktree or a separate branch, or when several threads will edit the same repo at once" },
1009 },
1010 required: ["title", "task"],
1011 },
1012 });
1013 await $.tool.register({
1014 name: T_HANDOFF,
1015 description:
1016 "Hand this chat's work off to a thread: it starts knowing what this conversation established and carries on with the work in progress, then reports back, so this chat is free (like Codex's handoff). Use when the user says hand this off, hand it over, let a thread finish this, or keep going in the background. next names the first step; leave it out to continue with the next steps the conversation named.",
1017 inputSchema: {
1018 type: "object",
1019 properties: {
1020 title: { type: "string" },
1021 next: { type: "string", description: "The first thing the thread should do (default: carry on where this conversation left off)" },
1022 model: { type: "string", description: "Default: this chat's model" },
1023 effort: { type: "string", enum: ["low", "medium", "high", "xhigh", "max"] },
1024 cwd: { type: "string" },
1025 include: { type: "string", enum: ["summary", "full"] },
1026 backend: { type: "string", enum: ["auto", "session", "inline"] },
1027 worktree: { type: "boolean", description: "Run it in its own git worktree and branch (the folder must be in a git repo), so parallel threads never edit the same files. Use when the user asks for isolation, a worktree or a separate branch, or when several threads will edit the same repo at once" },
1028 },
1029 required: ["title"],
1030 },
1031 });
1032 await $.tool.register({
1033 name: T_RENAME,
1034 description: "Rename a thread (shown as \"Thread | <title>\" in the pane, the list and, for an idle session thread, its session name). Use when the user asks to rename or retitle a thread.",
1035 inputSchema: { type: "object", properties: { id: idProp, title: { type: "string", description: "The new name" } }, required: ["id", "title"] },
1036 });
1037 await $.tool.register({
1038 name: T_MARK_READ,
1039 description: "Mark a thread's new output as read (id), or every thread of this chat (all: true or no id). Threads with a report the user has not looked at show as new in the pane, the list and the band.",
1040 inputSchema: { type: "object", properties: { id: idProp, all: { type: "boolean" } } },
1041 });
1042 await $.tool.register({
1043 name: T_ADOPT,
1044 description: "Adopt threads whose lead chat is gone (orphans): this chat becomes their lead, gets their reports and can steer them. ids names them, or all: true adopts every orphan. threads_setup lists orphans.",
1045 inputSchema: { type: "object", properties: { ids: { type: "array", items: { type: "string" } }, all: { type: "boolean" } } },
1046 });
1047 await $.tool.register({
1048 name: T_HISTORY,
1049 description: "Plan history: with no planId, lists saved plans (title, status, phases, cost); with a planId, shows that plan's full record (phases, models, threads, handoffs, gate decisions, timing, cost).",
1050 inputSchema: { type: "object", properties: { planId: { type: "string" } } },
1051 });
1052 await $.tool.register({
1053 name: T_PIN,
1054 description: "Pin a thread (pinned: true, default) so it stays on top and is never cleaned up or idle-closed; pinned: false unpins.",
1055 inputSchema: { type: "object", properties: { id: idProp, pinned: { type: "boolean" } }, required: ["id"] },
1056 });
1057 await $.tool.register({
1058 name: T_ARCHIVE,
1059 description: "Archive a thread (archived: true, default) to hide it from the pane and threads_list; archived: false brings it back.",
1060 inputSchema: { type: "object", properties: { id: idProp, archived: { type: "boolean" } }, required: ["id"] },
1061 });
1062 await $.tool.register({
1063 name: T_SETUP,
1064 description:
1065 "Check what threads need and say exactly what to fix: the terminal CLI login (session threads), tmux, whether this folder is trusted, the live-thread cap and stale threads, the default permission mode and how the mod is loaded. " +
1066 "Use it when the user asks to set up or check threads, or when creating a thread failed. Set close_stale: true only when the user explicitly asked to close the stale threads it lists.",
1067 inputSchema: {
1068 type: "object",
1069 properties: {
1070 cwd: { type: "string", description: "Folder to check for trust (default: this chat's)" },
1071 close_stale: { type: "boolean", description: "Close the stale threads found (only when the user asked)" },
1072 },
1073 },
1074 });
1075 await $.tool.register({
1076 name: T_CLOSE,
1077 description:
1078 "Close a thread (or every thread of a plan with all_of_plan): ends its Claude Code process; the transcript is kept and can be resumed. " +
1079 "Set confirmed: true ONLY when the user explicitly asked in plain words to close that thread, those threads or that plan's threads; then it closes without a dialog. " +
1080 "Otherwise leave it out: a confirmation dialog is shown, and if none can be shown the result tells you to ask the user in chat first.",
1081 inputSchema: {
1082 type: "object",
1083 properties: {
1084 id: idProp,
1085 all_of_plan: { type: "string", description: "A plan id: close every open thread of that plan instead of one thread" },
1086 confirmed: { type: "boolean", description: "True only when the user explicitly asked to close it in plain words" },
1087 },
1088 },
1089 });
1090}
1091
1092function startTicker($) {
1093 if (ticker) return;
1094 ticker = $.clock.every(TICK_MS, () => {
1095 void tick($);
1096 });
1097}
1098
1099async function tick($) {
1100 if (inflight) return;
1101 const { value: view = EMPTY_VIEW } = await $.state.get(VIEW);
1102 const now = await $.clock.now();
1103 const isOpen = (await $.ui.panes()).some((p) => p.id === PANE);
1104 const anyWorking = view.threads.some((t) => t.status === "working" || t.status === "starting");
1105 const hasMine = view.threads.some((t) => t.isMine && LIVE.has(t.status));
1106 let due = 0;
1107 if (isOpen && anyWorking) due = FAST_MS;
1108 else if (isOpen || hasMine) due = SLOW_MS;
1109 if (!due || now - view.refreshedAt < due - 200) return;
1110 await refresh($);
1111}
1112
1113// ---- paths and small host calls -----------------------------------------------------------------
1114
1115async function paths($) {
1116 const home = (await $.env.get("HOME")) ?? "";
1117 const configEnv = await $.env.get("CLAUDE_CONFIG_DIR");
1118 const config = configEnv || `${home}/.claude`;
1119 return {
1120 home,
1121 config,
1122 dir: `${config}/threads`,
1123 registry: `${config}/threads/registry.json`,
1124 events: `${config}/threads/events.jsonl`,
1125 sessions: `${config}/sessions`,
1126 projects: `${config}/projects`,
1127 claudeJson: configEnv ? `${configEnv}/.claude.json` : `${home}/.claude.json`,
1128 };
1129}
1130
1131async function run($, argv, timeoutMs = 10000, stdin = undefined) {
1132 try {
1133 const init = stdin === undefined ? { timeoutMs } : { timeoutMs, stdin };
1134 return await $.process.run(argv, init);
1135 } catch (err) {
1136 return { exitCode: 127, stdout: "", stderr: String(err?.message ?? err) };
1137 }
1138}
1139
1140async function ensureDir($, p) {
1141 if (dirReady) return;
1142 await run($, ["mkdir", "-p", p.dir]);
1143 dirReady = true;
1144}
1145
1146async function logEvent($, p, type, data) {
1147 await ensureDir($, p);
1148 const now = await $.clock.now();
1149 const line = JSON.stringify({ at: new Date(now).toISOString(), ts: now, type, ...data });
1150 await run($, ["tee", "-a", p.events], 5000, `${line}\n`);
1151}
1152
1153async function loadRegistry($, p) {
1154 let text = null;
1155 try {
1156 if (await $.fs.exists(p.registry)) text = await $.fs.read(p.registry);
1157 } catch {
1158 text = null;
1159 }
1160 const parsed = parseRegistry(text);
1161 if (parsed.isCorrupt) {
1162 const now = await $.clock.now();
1163 const backup = `${p.registry}.corrupt-${now}`;
1164 await run($, ["mv", "-f", p.registry, backup]);
1165 await logEvent($, p, "registry-reset", { backup });
1166 $.ui.toast(`Threads: the registry did not parse; backed up to ${shortPath(backup, 60)} and started fresh.`, { timeoutMs: 8000 });
1167 }
1168 return parsed.registry;
1169}
1170
1171async function saveRegistry($, p, reg) {
1172 await ensureDir($, p);
1173 const now = await $.clock.now();
1174 const tmp = `${p.registry}.tmp-${now}-${Math.floor(Math.random() * 1e6)}`;
1175 await $.fs.write(tmp, serializeRegistry(reg));
1176 const moved = await run($, ["mv", "-f", tmp, p.registry]);
1177 if (moved.exitCode !== 0) {
1178 await $.fs.write(p.registry, serializeRegistry(reg));
1179 await run($, ["rm", "-f", tmp]);
1180 }
1181}
1182
1183// Read, change, write. Writes from this process run one at a time (the watcher, a refresh and a
1184// report can overlap); two chats writing at the same moment can still collide, rarely.
1185// Across processes (several chats share the file), a lock directory: mkdir is atomic, so only one
1186// chat holds <registry>.lock at a time; a lock older than 15 s is taken as stale and removed.
1187let registryLock = Promise.resolve();
1188async function mutateRegistry($, p, change) {
1189 const turn = registryLock.then(async () => {
1190 const held = await lockRegistry($, p);
1191 try {
1192 const reg = await loadRegistry($, p);
1193 const out = change(reg) ?? reg;
1194 await saveRegistry($, p, out);
1195 return out;
1196 } finally {
1197 if (held) await run($, ["rmdir", `${p.registry}.lock`], 3000);
1198 }
1199 });
1200 registryLock = turn.catch(() => undefined);hooks/core.mjs 1332 lines1// Threads: pure helpers (no `$`). Everything here is plain data in, plain data out,
2// so the tests can pin it down without an engine.
3
4export const TMUX_SOCKET = "cc-threads";
5export const TITLE_PREFIX = "Thread | ";
6export const DEFAULT_CAP = 4;
7export const MAX_CAP = 12;
8export const STATUSES = ["starting", "working", "idle", "needs-you", "held-message", "needs-login", "needs-trust", "exited", "closed"];
9export const LIVE = new Set(["starting", "working", "idle", "needs-you", "held-message", "needs-login", "needs-trust"]);
10export const ATTENTION = new Set(["needs-you", "held-message", "needs-login", "needs-trust"]);
11// Per-thread --settings. crossSessionInbound accept: Claude Code would otherwise hold messages
12// between sessions of different permission classes. skipDangerousModePermissionPrompt (read from
13// flag settings too): without it a bypass thread sits on the one-time bypass warning dialog.
14export function threadSettings(permissionMode) {
15 return JSON.stringify({
16 crossSessionInbound: "accept",
17 ...(permissionMode === "bypassPermissions" ? { skipDangerousModePermissionPrompt: true } : {}),
18 });
19}
20export const MODES = ["default", "acceptEdits", "plan", "auto", "bypassPermissions"];
21// The user's choice: threads run with bypassed permissions unless told otherwise.
22export const DEFAULT_MODE = "bypassPermissions";
23export const MODEL_ALIASES = ["haiku", "sonnet", "opus", "fable"];
24const MODEL_ID = /^claude-(haiku|sonnet|opus|fable|mythos)-[0-9][0-9a-z.-]*(\[1m\])?$/;
25const ALIAS_1M = /^(sonnet|opus|fable)\[1m\]$/;
26
27// Variables a nested Claude Code would otherwise inherit from the lead and mistake
28// for its own (it would think it is a child of the lead, reuse its socket, and so on).
29export const SCRUB_ENV = [
30 "CLAUDECODE",
31 "CLAUDE_CODE_SESSION_ID",
32 "CLAUDE_CODE_CHILD_SESSION",
33 "CLAUDE_CODE_HOST_SESSION_ID",
34 "CLAUDE_CODE_MESSAGING_SOCKET",
35 "CLAUDE_CODE_MESSAGING_TOKEN",
36 "CLAUDE_PID",
37 "CLAUDE_CODE_ENTRYPOINT",
38 "CLAUDE_CODE_SESSION_ATTENDED",
39 "CLAUDE_CODE_TERMINAL_MCP_TOOLS",
40 "CLAUDE_CODE_DISABLE_TERMINAL_TITLE",
41 "CLAUDE_EFFORT",
42 "CLAUDE_PLUGIN_DATA",
43 "CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES",
44 "CLAUDE_CODE_REPORT_FINDINGS",
45 "CLAUDE_PREVIEW_CLASSIFIER_FLOOR",
46 "CLAUDE_AGENT_SDK_VERSION",
47 "CLAUDE_CODE_EXECPATH",
48 "TMUX",
49 "TERM_PROGRAM",
50];
51
52export const LOGIN_HINT = "Threads need the terminal Claude Code login. Run `claude auth login` in Terminal once.";
53
54// ---- models -------------------------------------------------------------------
55
56export function normalizeModel(raw) {
57 const m = String(raw ?? "").trim().toLowerCase();
58 if (MODEL_ALIASES.includes(m) || ALIAS_1M.test(m) || MODEL_ID.test(m)) return { model: m };
59 return {
60 error: `Unknown model "${raw}". Use haiku, sonnet, opus, fable, or a full id like claude-haiku-4-5-20251001.`,
61 };
62}
63
64// A short chip for the tree: the family name.
65export function modelChip(model) {
66 const m = String(model ?? "").toLowerCase();
67 for (const f of ["haiku", "sonnet", "opus", "fable", "mythos"]) if (m.includes(f)) return f;
68 return m ? m.slice(0, 8) : "?";
69}
70
71// ---- titles, ids, refs ----------------------------------------------------------
72
73// ---- worktrees, unread, renames -------------------------------------------------------
74
75export function worktreeName(id) {
76 return `threads-${id}`;
77}
78
79// Where Claude Code puts a --worktree session (and where inline threads get theirs)
80export function worktreeFor(repo, id) {
81 const name = worktreeName(id);
82 return { name, repo, path: `${repo}/.claude/worktrees/${name}`, branch: `worktree-${name}` };
83}
84
85export function worktreeLine(w) {
86 return `You work in your own git worktree (${w.path}, branch ${w.branch}), isolated from the lead and other threads, so edit freely there. Before your final answer, commit your finished work on that branch (git add -A && git commit -m "<what you did>") so the lead can review and merge it.`;
87}
88
89// After a worktree thread ends: what to do with it, from git status --porcelain and the commit count past its base
90export function worktreeOutcome({ statusOk, porcelain, commits }) {
91 const dirty = String(porcelain ?? "").trim().length > 0;
92 if (statusOk && !dirty && !commits) return { remove: true, dirty: false, commits: 0 };
93 return { remove: false, dirty, commits: Number(commits) || 0 };
94}
95
96// New output the user has not looked at: a report newer than the last time they viewed the thread
97export function isUnread(t) {
98 if (t.status === "closed" && !t.lastReport) return false;
99 return Boolean(t.lastReport?.at) && (t.lastReport.at ?? 0) > (t.seenAt ?? 0);
100}
101
102export function threadTitle(raw) {
103 const t = oneLine(raw).slice(0, 80);
104 if (!t) return "";
105 return /^thread\s*\|/i.test(t) ? t : `${TITLE_PREFIX}${t}`;
106}
107
108export function shortTitle(title) {
109 return String(title ?? "").replace(/^thread\s*\|\s*/i, "");
110}
111
112export function tmuxName(id) {
113 return `thread-${id}`;
114}
115
116// Short id: "t" plus five hex characters of the session uuid, unique in the registry.
117export function shortId(uuid, taken) {
118 const hex = String(uuid).replace(/[^0-9a-f]/gi, "").toLowerCase();
119 for (let n = 5; n <= hex.length; n++) {
120 const id = `t${hex.slice(0, n)}`;
121 if (!taken.has(id)) return id;
122 }
123 return `t${hex}`;
124}
125
126// Finds one thread by id, id prefix or title prefix. Live threads win over closed ones.
127export function resolveRef(threads, ref) {
128 const q = String(ref ?? "").trim().toLowerCase();
129 if (!q) return { error: "Name a thread by its id or the start of its title. /threads list shows them." };
130 const exact = threads.filter((t) => t.id === q);
131 if (exact.length === 1) return { thread: exact[0] };
132 const hits = threads.filter(
133 (t) =>
134 t.id.startsWith(q) ||
135 shortTitle(t.title).toLowerCase().startsWith(q) ||
136 t.title.toLowerCase().startsWith(q) ||
137 t.sessionId === q,
138 );
139 if (hits.length === 1) return { thread: hits[0] };
140 const live = hits.filter((t) => LIVE.has(t.status));
141 if (live.length === 1) return { thread: live[0] };
142 if (hits.length === 0) return { error: `No thread matches "${ref}". /threads list shows them.` };
143 const pool = live.length > 1 ? live : hits;
144 return {
145 error: `"${ref}" matches ${pool.length} threads: ${pool.map((t) => `${t.id} (${shortTitle(t.title)})`).join(", ")}. Use the id.`,
146 };
147}
148
149// ---- argument parsing -------------------------------------------------------------
150
151// Splits on spaces, keeping "quoted words" together.
152export function tokenize(text) {
153 const out = [];
154 const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
155 let m;
156 while ((m = re.exec(String(text ?? "")))) out.push(m[1] ?? m[2] ?? m[3]);
157 return out;
158}
159
160// `/threads new <model> <title> [--cwd <path>] [--mode <mode>] [--no-report] -- <task>`
161export function parseNew(args) {
162 const text = String(args ?? "");
163 const sep = /(^|\s)--(\s|$)/.exec(text);
164 if (!sep) return { error: NEW_USAGE };
165 const head = text.slice(0, sep.index);
166 const task = text.slice(sep.index + sep[0].length).trim();
167 const tokens = tokenize(head);
168 const [model = "", ...rest] = tokens;
169 const words = [];
170 const out = { model, title: "", task, cwd: undefined, permissionMode: undefined, reportBack: true, backend: "auto" };
171 for (let i = 0; i < rest.length; i++) {
172 const tok = rest[i];
173 if (tok === "--cwd") {
174 out.cwd = rest[++i];
175 if (!out.cwd) return { error: "--cwd needs a folder." };
176 } else if (tok === "--mode") {
177 out.permissionMode = rest[++i];
178 if (!out.permissionMode) return { error: "--mode needs one of default, acceptEdits, plan, auto." };
179 } else if (tok === "--effort") {
180 out.effort = rest[++i];
181 if (!out.effort) return { error: "--effort needs one of low, medium, high, xhigh, max." };
182 } else if (tok === "--no-report") {
183 out.reportBack = false;
184 } else if (tok === "--worktree") {
185 out.worktree = true;
186 } else if (tok === "--inline" || tok === "--session") {
187 out.backend = tok.slice(2);
188 } else {
189 words.push(tok);
190 }
191 }
192 out.title = words.join(" ");
193 if (!out.model || !out.title || !out.task) return { error: NEW_USAGE };
194 return out;
195}
196
197export const NEW_USAGE =
198 "Usage is /threads new <model> <title> [--inline|--session] [--cwd <path>] [--mode bypassPermissions|default|acceptEdits|plan|auto] [--effort <level>] [--worktree] [--no-report] -- <task>";
199
200export function checkMode(mode) {
201 if (mode === undefined || mode === null || mode === "") return { mode: undefined };
202 const want = String(mode).toLowerCase() === "bypass" ? "bypasspermissions" : String(mode).toLowerCase();
203 const hit = MODES.find((m) => m.toLowerCase() === want);
204 if (!hit) return { error: `Unknown permission mode "${mode}". Use bypassPermissions, default, acceptEdits, plan or auto.` };
205 return { mode: hit };
206}
207
208// ---- the thread's brief and argv -----------------------------------------------------
209
210export function leadAddress(socket) {
211 return socket ? `uds:${socket}` : "";
212}
213
214export function buildBrief({ title, leadTitle, leadId, task, cwd, reportBack, leadSocket, context, worktree }) {
215 const lines = [
216 `You are a Claude Code worker session ("thread") titled "${title}".`,
217 `You were created by the lead chat "${leadTitle}" (session ${leadId}), which watches your work and may steer you.`,
218 `Your task is the first prompt of this session. In short: ${clip(oneLine(task), 600)}`,
219 `Your working folder is ${cwd}. Other sessions, the lead and other threads, may be editing this codebase at the same time, so re-read a file right before you edit it and keep changes scoped to your task.`,
220 ...(worktree ? [worktreeLine(worktree)] : []),
221 "Messages from the lead can arrive mid-task as cross-session messages. Treat them as instructions from the lead and adjust.",
222 ];
223 const address = leadAddress(leadSocket);
224 if (reportBack && address) {
225 lines.push(
226 `The lead sees your final answer automatically when you go idle, so finish each turn with a complete answer. Optionally, if you get blocked or need a decision mid-task, you may also send ONE short note to the lead with your SendMessage tool, with "to" set to exactly "${address}". Claude Code may hold that note on the lead's side (different permission modes); that is fine, the lead still gets your final answer. Do not send progress chatter.`,
227 );
228 } else if (reportBack) {
229 lines.push("The lead sees your final answer automatically when you go idle, so finish each turn with a complete answer.");
230 } else {
231 lines.push("Do not message the lead. End with a short summary of what you did.");
232 }
233 if (context) lines.push("", forkContextBlock(context, leadTitle));
234 return lines.join("\n");
235}
236
237// What a forked thread carries from the lead: a summary inline, or a file to read first.
238export function forkContextBlock(context, leadTitle) {
239 if (context.file) {
240 return `You were forked from the lead chat "${leadTitle}". Before your task, read ${context.file}: it holds that conversation so far. Continue from it.`;
241 }
242 return [`You were forked from the lead chat "${leadTitle}". What that conversation established so far:`, String(context.summary ?? "").trim(), "Continue from this context."].join("\n");
243}
244
245export function buildSpawnArgv({ name, cwd, model, sessionId, title, permissionMode, brief, task, effort, worktree }) {
246 const scrub = SCRUB_ENV.flatMap((v) => ["-u", v]);
247 const claude = ["claude", "--model", model, "--session-id", sessionId, "-n", title, "--remote-control", title, "--settings", threadSettings(permissionMode)];
248 if (effort) claude.push("--effort", effort);
249 // Claude Code makes the worktree itself (<repo>/.claude/worktrees/<name>, branch worktree-<name>) and keeps the launch folder's trust
250 if (worktree) claude.push("--worktree", worktree);
251 if (permissionMode && permissionMode !== "default") claude.push("--permission-mode", permissionMode);
252 claude.push("--append-system-prompt", brief);
253 // A prompt that starts with "-" would be read as a flag.
254 claude.push(/^\s*-/.test(task) ? `Task: ${task}` : task);
255 return [
256 "tmux", "-L", TMUX_SOCKET, "-f", "/dev/null",
257 "new-session", "-d", "-s", name, "-x", "200", "-y", "50", "-c", cwd,
258 "env", ...scrub, "TERM=xterm-256color", "COLORTERM=truecolor",
259 ...claude,
260 // keep the pane after claude exits so its last screen can still be read
261 ";", "set-option", "-t", name, "remain-on-exit", "on",
262 ];
263}
264
265export function tmuxArgv(...rest) {
266 return ["tmux", "-L", TMUX_SOCKET, "-f", "/dev/null", ...rest];
267}
268
269// `list-panes -a -F '#{session_name}|#{pane_dead}|#{pane_pid}'`
270export function parsePanes(stdout) {
271 const map = new Map();
272 for (const line of String(stdout ?? "").split("\n")) {
273 const [name, dead, pid] = line.trim().split("|");
274 if (!name) continue;
275 map.set(name, { isDead: dead === "1", panePid: Number(pid) || 0 });
276 }
277 return map;
278}
279
280export const PANE_FORMAT = "#{session_name}|#{pane_dead}|#{pane_pid}";
281
282// ---- trust -------------------------------------------------------------------------
283
284// Is the folder trusted in ~/.claude.json? Both /tmp and /private/tmp spellings. Exact folder only:
285// a live check showed Claude Code asking again in a git repo inside a trusted folder.
286export function isTrusted(claudeJson, path) {
287 let projects;
288 try {
289 projects = JSON.parse(claudeJson)?.projects ?? {};
290 } catch {
291 return false;
292 }
293 const p = String(path).replace(/\/+$/, "") || "/";
294 const spellings = new Set([p]);
295 if (p.startsWith("/private/")) spellings.add(p.slice("/private".length));
296 else if (/^\/(tmp|var|etc)(\/|$)/.test(p)) spellings.add(`/private${p}`);
297 return [...spellings].some((x) => projects[x]?.hasTrustDialogAccepted === true);
298}
299
300// ---- the screen ----------------------------------------------------------------------
301
302const LOGIN = /Login expired|Not logged in|Please run \/login|Invalid API key|OAuth token (has )?expired|run `?claude auth login/i;
303const TRUST = /Do you trust the files in this folder|Yes, I trust this folder|Is this a project you created or one you trust/i;
304const YES_OPTION = /^\s*[❯>›]?\s*1\.\s+Yes\b/m;
305
306const HELD = /Held message from another session/;
307// the codex-computer-use mod's approval pane: "Allow Codex computer use to use <App>?"
308const CU_ASK = /Allow Codex computer use to use (.+?)\?/;
309const BYPASS_WARNING = /WARNING: Claude Code running in Bypass Permissions mode|you accept all responsibility for actions taken while running in Bypass Permissions mode/i;
310
311export function readScreen(text) {
312 // box borders (│ on either side) are dropped so the option lines read plainly
313 const lines = String(text ?? "")
314 .replace(/\s+$/g, "")
315 .split("\n")
316 .map((l) => l.replace(/^[\s│┃]+/, (m) => m.replace(/[│┃]/g, " ")).replace(/[\s│┃]+$/, ""));
317 const tail = lines.slice(-30).join("\n");
318 const needsLogin = LOGIN.test(lines.join("\n"));
319 const needsTrust = TRUST.test(tail);
320 const needsYou = !needsTrust && /Do you want to|Would you like to proceed/.test(tail) && YES_OPTION.test(tail);
321 let prompt = "";
322 if (needsYou) {
323 const tl = lines.slice(-30);
324 let start = tl.findIndex((l) => /Do you want to|Would you like to proceed/.test(l));
325 start = Math.max(0, start - 8);
326 prompt = tl
327 .slice(start)
328 .map((l) => l.replace(/[│╭╮╰╯─]+/g, " ").replace(/\s+$/, ""))
329 .filter((l) => l.trim())
330 .slice(-16)
331 .join("\n");
332 }
333 const isCursorOnYes = needsYou && /^\s*[❯>›]\s*1\.\s+Yes\b/m.test(tail);
334 // "Held message from another session" dialog: the body it would deliver follows "Message body"
335 const heldMessage = HELD.test(lines.join("\n"));
336 let heldPreview = "";
337 if (heldMessage) {
338 const all = lines.map((l) => l.replace(/[╭╮╰╯─]+/g, " ").trim());
339 const at = all.findIndex((l) => /Message body/.test(l));
340 if (at >= 0) {
341 const body = [];
342 for (const l of all.slice(at + 1)) {
343 if (/^(❯\s*)?\d+\.\s|^Deny\b|^Deliver\b|Esc to|Enter to/.test(l)) break;
344 if (l) body.push(l);
345 }
346 heldPreview = oneLine(body.join(" "));
347 }
348 }
349 // the one-time bypass warning (should not appear: threads get skipDangerousModePermissionPrompt)
350 const bypassWarning = BYPASS_WARNING.test(lines.join("\n"));
351 if (bypassWarning) {
352 return { needsLogin, needsTrust, needsYou: true, prompt: "the bypass permissions warning (Yes, I accept / No, exit)", isCursorOnYes: false, heldMessage: false, heldPreview: "", bypassWarning };
353 }
354 // a thread waiting on Codex computer use's per-app approval (keys: a this session, l always, d deny)
355 const cu = CU_ASK.exec(tail);
356 if (cu && !heldMessage) {
357 return { needsLogin, needsTrust, needsYou: true, prompt: `Codex computer use wants to use ${cu[1]} (approve allows it for that thread's session)`, isCursorOnYes: false, heldMessage: false, heldPreview: "", bypassWarning: false, cuApproval: cu[1] };
358 }
359 return { needsLogin, needsTrust, needsYou: needsYou && !heldMessage, prompt, isCursorOnYes, heldMessage, heldPreview, bypassWarning: false };
360}
361
362// Last N non-empty screen lines, trailing spaces trimmed.
363export function screenLines(text, n) {
364 const lines = [];
365 for (const raw of String(text ?? "").split("\n")) {
366 const l = raw.replace(/\s+$/, "");
367 // runs of blank rows (the empty middle of a fresh screen) fold to one
368 if (!l && lines.length && !lines[lines.length - 1]) continue;
369 lines.push(l);
370 }
371 while (lines.length && !lines[lines.length - 1]) lines.pop();
372 while (lines.length && !lines[0]) lines.shift();
373 return lines.slice(-n);
374}
375
376// ---- status ----------------------------------------------------------------------------
377
378// tmux: { isLive, isDead }, session: the ~/.claude/sessions json or null, pidAlive, screen: readScreen()
379export function statusOf({ previous, tmux, session, pidAlive, screen, now, createdAt }) {
380 if (previous === "closed") return "closed";
381 if (!tmux?.isLive || tmux.isDead) return "exited";
382 if (screen?.needsLogin) return "needs-login";
383 if (screen?.needsTrust) return "needs-trust";
384 if (screen?.heldMessage) return "held-message";
385 if (screen?.needsYou) return "needs-you";
386 if (session && pidAlive !== false) {
387 if (session.status === "busy") return "working";
388 if (session.status === "waiting") return "needs-you";
389 if (session.status === "idle") return "idle";
390 }
391 if (now - (createdAt ?? now) < 120000) return "starting";
392 return session ? "idle" : "starting";
393}
394
395export function dotOf(status) {
396 return (
397 {
398 starting: "◌",
399 working: "●",
400 idle: "○",
401 "needs-you": "◆",
402 "held-message": "✉",
403 "needs-login": "◆",
404 "needs-trust": "◆",
405 exited: "×",
406 closed: "·",
407 }[status] ?? "?"
408 );
409}
410
411export function colorOf(status) {
412 return (
413 {
414 starting: "cyan",
415 working: "yellow",
416 idle: "green",
417 "needs-you": "magenta",
418 "held-message": "magenta",
419 "needs-login": "red",
420 "needs-trust": "red",
421 exited: "gray",
422 closed: "gray",
423 }[status] ?? undefined
424 );
425}
426
427// ---- transcripts ---------------------------------------------------------------------------
428
429export function slug(path) {
430 return String(path).replace(/[^a-zA-Z0-9]/g, "-");
431}
432
433export function toolLine(name, input) {
434 const i = input && typeof input === "object" ? input : {};
435 const first = (s) => oneLine(String(s ?? "").split("\n")[0]);
436 switch (name) {
437 case "Read":
438 case "Write":
439 case "Edit":
440 case "MultiEdit":
441 case "NotebookEdit":
442 return `${name} ${i.file_path ?? i.notebook_path ?? ""}`.trim();
443 case "Bash":
444 return `Bash ${first(i.command)}`;
445 case "Grep":
446 return `Grep ${i.pattern ?? ""}${i.path ? ` in ${i.path}` : ""}`;
447 case "Glob":
448 return `Glob ${i.pattern ?? ""}`;
449 case "WebFetch":
450 return `WebFetch ${i.url ?? ""}`;
451 case "WebSearch":
452 return `WebSearch ${i.query ?? ""}`;
453 case "Task":
454 case "Agent":
455 return `${name} ${i.description ?? i.subagent_type ?? ""}`.trim();
456 case "SendMessage":
457 return `SendMessage to ${i.to ?? i.recipient ?? "?"}`;
458 case "TodoWrite":
459 return "TodoWrite";
460 default: {
461 const json = Object.keys(i).length ? ` ${JSON.stringify(i)}` : "";
462 return clip(`${name}${json}`, 160);
463 }
464 }
465}
466
467function blockText(content) {
468 if (typeof content === "string") return content;
469 if (!Array.isArray(content)) return "";
470 return content
471 .filter((b) => b && b.type === "text" && typeof b.text === "string")
472 .map((b) => b.text)
473 .join(" ");
474}
475
476// The tail of a transcript, as display items plus the model of the latest assistant row.
477export function parseTranscript(jsonl) {
478 const items = [];
479 let model = "";
480 let lastAt = 0;
481 // the newest assistant row with text, whole (not one-lined), and whether anything user-side came after it
482 let lastAnswer = null;
483 let afterAnswer = false;
484 for (const raw of String(jsonl ?? "").split("\n")) {
485 const line = raw.trim();
486 if (!line.startsWith("{")) continue;
487 let row;
488 try {
489 row = JSON.parse(line);
490 } catch {
491 continue; // the first line of a tail is usually cut
492 }
493 const at = Date.parse(row.timestamp ?? "") || 0;
494 if (at) lastAt = Math.max(lastAt, at);
495 if (row.isSidechain) continue;
496 if (row.type === "assistant" && row.message) {
497 if (typeof row.message.model === "string" && row.message.model && !row.message.model.startsWith("<")) {
498 model = row.message.model;
499 }
500 if (row.isApiErrorMessage || row.error) {
501 items.push({ kind: "error", text: oneLine(blockText(row.message.content) || String(row.error)), at });
502 continue;
503 }
504 const blocks = Array.isArray(row.message.content) ? row.message.content : [];
505 for (const b of blocks) {
506 if (b?.type === "text" && b.text?.trim()) items.push({ kind: "assistant", text: oneLine(b.text), at });
507 else if (b?.type === "tool_use") items.push({ kind: "tool", text: toolLine(b.name, b.input), at });
508 }
509 const whole = blocks.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
510 if (whole) {
511 lastAnswer = { text: whole, at, key: `${row.uuid ?? row.message.id ?? at}:${whole.length}` };
512 afterAnswer = false;
513 } else if (blocks.some((b) => b?.type === "tool_use")) {
514 afterAnswer = true; // still working after that text
515 }
516 } else if (row.type === "user" && row.message && !row.isMeta) {
517 const c = row.message.content;
518 const isToolResult = Array.isArray(c) && c.length > 0 && c.every((b) => b?.type === "tool_result");
519 if (!isToolResult && lastAnswer) afterAnswer = true;
520 if (typeof c === "string") {
521 pushUserText(items, c, at);
522 } else if (Array.isArray(c)) {
523 for (const b of c) {
524 if (b?.type === "tool_result" && b.is_error) {
525 items.push({ kind: "error", text: oneLine(blockText(b.content) || String(b.content ?? "")), at });
526 } else if (b?.type === "text" && typeof b.text === "string") {
527 pushUserText(items, b.text, at);
528 }
529 }
530 }
531 } else if (row.type === "system" && (row.level === "error" || row.subtype === "api_error")) {
532 items.push({ kind: "error", text: oneLine(row.content ?? row.subtype ?? "error"), at });
533 }
534 }
535 return { items, model, lastAt, lastAnswer, isAnswerLatest: Boolean(lastAnswer) && !afterAnswer };
536}
537
538function pushUserText(items, text, at) {
539 const t = String(text);
540 if (!t.trim()) return;
541 if (t.includes("<cross-session-message")) {
542 const env = envelopeOf(t);
543 items.push({ kind: "message", text: `from ${env.fromName || env.from || "a session"}: ${stripTags(t)}`, at });
544 return;
545 }
546 const cmd = /<command-name>([^<]*)<\/command-name>/.exec(t);
547 if (cmd) {
548 const args = /<command-args>([^<]*)<\/command-args>/.exec(t)?.[1] ?? "";
549 items.push({ kind: "user", text: oneLine(`${cmd[1]} ${args}`), at });
550 return;
551 }
552 if (/^\s*<(local-command|system-reminder|bash-|task-notification)/.test(t)) return;
553 if (/^\[Request interrupted/.test(t.trim())) {
554 items.push({ kind: "error", text: oneLine(t), at });
555 return;
556 }
557 items.push({ kind: "user", text: oneLine(t), at });
558}
559
560// ---- reports -----------------------------------------------------------------------------
561
562export function envelopeOf(text) {
563 const tag = /<cross-session-message\b([^>]*)>/.exec(String(text ?? ""))?.[1] ?? "";
564 const attr = (n) => new RegExp(`\\s${n}="([^"]*)"`).exec(tag)?.[1] ?? "";
565 return { from: attr("from"), fromName: attr("from-name"), isEnvelope: tag !== "" || /<cross-session-message/.test(text ?? "") };
566}
567
568export function stripTags(text) {
569 return oneLine(String(text ?? "").replace(/<[^>]+>/g, " "));
570}
571
572// Which registered thread a delivery came from: its socket, its pid's socket, or its exact title.
573export function threadOfDelivery(text, threads) {
574 const env = envelopeOf(text);
575 if (!env.isEnvelope) return undefined;
576 threads = threads.filter((t) => t.backend !== "inline");
577 if (env.from) {
578 const bySocket = threads.find(
579 (t) => (t.socket && (env.from === `uds:${t.socket}` || env.from.endsWith(t.socket))) || (t.pid && env.from.endsWith(`/${t.pid}.sock`)),
580 );
581 if (bySocket) return bySocket;
582 const bySession = threads.find(
583 (t) => (t.sessionId && env.from.includes(t.sessionId)) || (t.bridgeSessionId && env.from.includes(t.bridgeSessionId)),
584 );
585 if (bySession) return bySession;
586 }
587 if (env.fromName) {
588 const named = threads.filter((t) => t.title === env.fromName);
589 if (named.length === 1) return named[0];
590 const live = named.filter((t) => LIVE.has(t.status));
591 if (live.length === 1) return live[0];
592 }
593 return undefined;
594}
595
596// ---- registry ------------------------------------------------------------------------------
597
598export function emptyRegistry() {
599 return { version: 1, cap: DEFAULT_CAP, threads: [], plans: [] };
600}
601
602// Parses the registry file; a file that does not parse is reported, never thrown.
603export function parseRegistry(text) {
604 if (text === null || text === undefined || String(text).trim() === "") return { registry: emptyRegistry(), isCorrupt: false };
605 try {
606 const data = JSON.parse(text);
607 if (!data || typeof data !== "object" || !Array.isArray(data.threads)) return { registry: emptyRegistry(), isCorrupt: true };
608 const cap = Number.isInteger(data.cap) && data.cap >= 1 && data.cap <= MAX_CAP ? data.cap : DEFAULT_CAP;
609 const threads = data.threads.filter((t) => t && typeof t.id === "string" && typeof t.sessionId === "string");
610 const plans = Array.isArray(data.plans) ? data.plans.filter((x) => x && typeof x.id === "string" && Array.isArray(x.phases)) : [];
611 // keys a newer version wrote are kept as they are, so an older copy of the mod never drops them
612 const { version: _v, cap: _c, threads: _t, plans: _p, ...rest } = data;
613 return { registry: { ...rest, version: 1, cap, threads, plans }, isCorrupt: false };
614 } catch {
615 return { registry: emptyRegistry(), isCorrupt: true };
616 }
617}
618
619export function serializeRegistry(reg) {
620 return `${JSON.stringify({ ...reg, version: 1, cap: reg.cap, threads: reg.threads, plans: reg.plans ?? [] }, null, 2)}\n`;
621}
622
623// Closed or exited entries older than the cutoff go; live ones always stay.
624export function cleanable(threads, now, maxAgeMs) {
625 return threads.filter((t) => !LIVE.has(t.status) && now - (t.closedAt || t.endedAt || t.createdAt || 0) > maxAgeMs);
626}
627
628// ---- formatting --------------------------------------------------------------------------------
629
630export function oneLine(s) {
631 return String(s ?? "").replace(/\s+/g, " ").trim();
632}
633
634export function clip(s, n) {
635 const t = String(s ?? "");
636 return t.length <= n ? t : `${t.slice(0, Math.max(0, n - 1))}…`;
637}
638
639export function shortPath(path, n) {
640 let p = String(path ?? "").replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
641 if (p.length <= n) return p;
642 return `…${p.slice(p.length - n + 1)}`;
643}
644
645export function age(ms) {
646 const s = Math.max(0, Math.round(ms / 1000));
647 if (s < 60) return `${s}s`;
648 const m = Math.floor(s / 60);
649 if (m < 60) return `${m}m`;
650 const h = Math.floor(m / 60);
651 if (h < 48) return `${h}h ${m % 60}m`;
652 return `${Math.floor(h / 24)}d`;
653}
654
655export function shellQuote(s) {
656 return `'${String(s).replace(/'/g, `'\\''`)}'`;
657}
658
659export function resumeCommand(t) {
660 return `cd ${shellQuote(t.cwd)} && claude --resume ${t.sessionId}`;
661}
662
663export function remoteLink(t) {
664 return t.bridgeSessionId ? `https://claude.ai/code/${t.bridgeSessionId}` : "";
665}
666
667export function itemLine(it) {
668 const tag = { user: "you", assistant: "says", tool: "tool", error: "error", message: "msg", step: "model", wait: "wait", done: "done" }[it.kind] ?? it.kind;
669 return `${tag.padEnd(5)} ${it.text}`;
670}
671
672// The band: counts of this lead's live threads.
673export function bandText(threads) {
674 const live = threads.filter((t) => LIVE.has(t.status));
675 if (live.length === 0) return "";
676 const working = live.filter((t) => t.status === "working" || t.status === "starting").length;
677 const needs = live.filter((t) => ATTENTION.has(t.status)).length;
678 const parts = [`⇶ ${live.length} thread${live.length === 1 ? "" : "s"}`];
679 if (working) parts.push(`${working} working`);
680 if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
681 const fresh = threads.filter(isUnread).length;
682 if (fresh) parts.push(`${fresh} new`);
683 parts.push("/threads");
684 return parts.join(" · ");
685}
686
687// ---- the inline backend ----------------------------------------------------------------------
688
689export const BACKENDS = ["auto", "session", "inline"];
690export const ACTIVITY_MAX = 60;
691
692// auto: a real session when the terminal login works, otherwise a subagent of this chat.
693export function chooseBackend(requested, isLoggedIn) {
694 const want = String(requested ?? "auto").toLowerCase();
695 if (!BACKENDS.includes(want)) return { error: `Unknown backend "${requested}". Use auto, session or inline.` };
696 if (want === "auto") return { backend: isLoggedIn ? "session" : "inline", isAuto: true };
697 return { backend: want, isAuto: false };
698}
699
700export function agentName(id) {
701 return `thread-${id}`;
702}
703
704// What an inline thread (a background subagent of the lead) is told before its task.
705export function buildInlinePrompt({ title, leadTitle, task, cwd, reportBack, context, worktree }) {
706 return [
707 ...(context ? [forkContextBlock(context, leadTitle), ""] : []),
708 `You are a worker ("thread") titled "${title}", started in the background by the lead chat "${leadTitle}", which watches your work and may send you messages mid-task. Treat those as instructions from the lead.`,
709 worktree
710 ? `Working folder: ${cwd}. ${worktreeLine(worktree)} Work only inside that folder.`
711 : `Working folder: ${cwd}. Other sessions and threads may edit this codebase at the same time, so re-read a file right before you edit it and keep changes scoped to your task.`,
712 reportBack
713 ? "When you finish or get blocked, end with a SHORT report as your final answer (at most 5 lines: outcome, key findings, any question). It reaches the lead automatically; do not use SendMessage for it."
714 : "End with a short summary of what you did.",
715 "",
716 "Task:",
717 task,
718 ].join("\n");
719}
720
721// agent.list status plus what the hooks saw.
722export function statusOfInline({ previous, isMine, agent, meta, leadAlive, now, createdAt }) {
723 if (previous === "closed") return "closed";
724 if (!isMine) return leadAlive === false && LIVE.has(previous) ? "exited" : previous;
725 if (meta?.needsYou) return "needs-you";
726 if (!agent) {
727 if (meta?.status === "working" || meta?.status === "idle") return meta.status;
728 return now - (createdAt ?? now) < 15000 && LIVE.has(previous) ? previous : "exited";
729 }
730 const st = String(agent.status ?? "").toLowerCase();
731 if (st === "running" || st === "pending") return meta?.status === "idle" ? "idle" : "working";
732 if (st === "completed") return "idle";
733 if (st === "failed" || st === "killed" || st === "stopped" || st === "cancelled") return "exited";
734 return meta?.status ?? "working";
735}
736
737// $.session.messages({ agentId }) rows as display items.
738export function messagesToItems(rows) {
739 const items = [];
740 for (const m of Array.isArray(rows) ? rows : []) {
741 if (m.role === "assistant") {
742 if (m.text && m.text.trim()) items.push({ kind: "assistant", text: oneLine(m.text) });
743 for (const u of Array.isArray(m.toolUses) ? m.toolUses : []) {
744 items.push({ kind: "tool", text: toolLine(u.tool ?? u.name, u.input) });
745 if (u.isError) items.push({ kind: "error", text: oneLine(u.text ?? "tool error") });
746 }
747 } else if (m.role === "user") {
748 const t = String(m.text ?? "");
749 if (!t.trim()) continue;
750 if (t.includes("<cross-session-message") || t.includes("<teammate-message")) {
751 const body = stripTags(t);
752 // the thread's own brief: show just its task
753 const task = /You are a worker \("thread"\)[\s\S]*?Task:\s*([\s\S]*)$/.exec(t.replace(/<[^>]+>/g, " "));
754 items.push(task ? { kind: "user", text: `task: ${oneLine(task[1])}` } : { kind: "message", text: body });
755 }
756 else if (!/^\s*<(system-reminder|local-command|task-notification)/.test(t)) items.push({ kind: "user", text: oneLine(t) });
757 }
758 }
759 return items;
760}
761
762export function pushBounded(list, item, max = ACTIVITY_MAX) {
763 return [...(Array.isArray(list) ? list : []), item].slice(-max);
764}
765
766export function activityLine(it) {
767 const at = it.at ? new Date(it.at) : null;
768 const two = (n) => String(n).padStart(2, "0");
769 const clock = at ? `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())} ` : "";
770 return `${clock}${itemLine(it)}`;
771}
772
773// ---- reports the lead model reads ------------------------------------------------------------
774
775export const ANSWER_MAX = 2000;
776export const REPORT_APPEND_MAX = 1500;
777export const PEER_DEDUPE_MS = 60000;
778
779// The row appended for the lead's model when a thread finishes.
780export function reportRow({ title, id, model, answer }) {
781 const body = clip(String(answer ?? "").trim(), REPORT_APPEND_MAX);
782 return {
783 message: {
784 type: "user",
785 content: [{ type: "text", text: `<thread report from ${title} (${id}), model ${model || "unknown"}>\n${body}\n</thread report>` }],
786 },
787 };
788}
789
790// Should a finished answer be recorded and appended? Not twice, and not right after the thread messaged the lead itself.
791export function shouldReport({ lastReport, appendedKey, key, now }) {
792 if (!key || key === appendedKey) return { report: false, reason: "already reported" };
793 if (lastReport?.source === "peer" && now - (lastReport.at ?? 0) < PEER_DEDUPE_MS) return { report: false, reason: "the thread reported itself" };
794 return { report: true };
795}
796
797// Is the held message on screen the one the lead just sent? Compare the start of each, spacing ignored.
798export function heldMatches(preview, sent) {
799 const a = oneLine(preview).toLowerCase();
800 const b = oneLine(sent).toLowerCase();
801 if (!a || !b) return false;
802 const n = Math.min(60, a.length, b.length);
803 return n >= 8 && (a.includes(b.slice(0, n)) || b.includes(a.slice(0, n)));
804}
805
806// ---- effort ------------------------------------------------------------------------------------
807
808export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
809
810export function checkEffort(raw) {
811 if (raw === undefined || raw === null || raw === "") return { effort: undefined };
812 const e = String(raw).toLowerCase();
813 return EFFORTS.includes(e) ? { effort: e } : { error: `Unknown effort "${raw}". Use low, medium, high, xhigh or max.` };
814}
815
816// ---- phase plans ---------------------------------------------------------------------------------
817//
818// A plan runs phases one at a time; each phase is a thread that ends by writing an
819// immutable handoff file, which the next phase starts from. Gates decide who lets
820// the next phase start: auto (at once), lead (the lead model reviews, then calls
821// threads_plan_advance) or user (/threads plan next).
822
823export const GATES = ["auto", "lead", "user"];
824export const PHASE_STATES = ["queued", "working", "awaiting-gate", "accepted", "revising", "blocked", "done"];
825export const MAX_PHASES = 8;
826export const MAX_REVISIONS = 2;
827
828const two = (n) => String(n).padStart(2, "0");
829
830export function phaseNumber(i) {
831 return two(i + 1);
832}
833
834export function stamp(ms) {
835 const d = new Date(ms);
836 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`;
837}
838
839// Local wall-clock time, like the handoff file names, so history reads in the user's own hours
840export function localTime(ms, withSeconds = true) {
841 const d = new Date(ms);
842 const t = `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
843 return withSeconds ? `${t}:${two(d.getSeconds())}` : t;
844}
845
846export function handoffPathFor(plan, i, ms) {
847 return `${plan.handoffDir}/plan-${plan.id}-phase-${phaseNumber(i)}-${stamp(ms)}.md`;
848}
849
850export function phaseTitle(plan, i) {
851 return `Thread | ${plan.title} | ${phaseNumber(i)} ${plan.phases[i].name}`;
852}
853
854// Checks and normalizes a threads_plan request (models, efforts, gates, at most MAX_PHASES phases).
855export function validatePlan(input) {
856 const title = oneLine(input?.title ?? "").slice(0, 40);
857 if (!title) return { error: "A plan needs a title." };
858 const gate = String(input?.gate ?? "lead").toLowerCase();
859 if (!GATES.includes(gate)) return { error: `Unknown gate "${input.gate}". Use auto, lead or user.` };
860 const raw = Array.isArray(input?.phases) ? input.phases : [];
861 if (raw.length === 0) return { error: "A plan needs at least one phase." };
862 if (raw.length > MAX_PHASES) return { error: `A plan has at most ${MAX_PHASES} phases.` };
863 const phases = [];
864 for (const [i, ph] of raw.entries()) {
865 const name = oneLine(ph?.name ?? "").slice(0, 40);
866 const task = String(ph?.task ?? "").trim();
867 if (!name || !task) return { error: `Phase ${i + 1} needs a name and a task.` };
868 const m = normalizeModel(ph.model);
869 if (m.error) return { error: `Phase ${i + 1}: ${m.error}` };
870 const e = checkEffort(ph.effort);
871 if (e.error) return { error: `Phase ${i + 1}: ${e.error}` };
872 const mode = checkMode(ph.permission_mode ?? ph.permissionMode);
873 if (mode.error) return { error: `Phase ${i + 1}: ${mode.error}` };
874 const pg = ph.gate === undefined || ph.gate === null || ph.gate === "" ? undefined : String(ph.gate).toLowerCase();
875 if (pg !== undefined && !GATES.includes(pg)) return { error: `Phase ${i + 1}: unknown gate "${ph.gate}".` };
876 phases.push({
877 name,
878 model: m.model,
879 effort: e.effort,
880 task,
881 acceptance: String(ph.acceptance ?? "").trim(),
882 permissionMode: mode.mode,
883 gate: pg,
884 status: "queued",
885 threadId: "",
886 handoffPath: "",
887 revisions: 0,
888 nudges: 0,
889 startedAt: 0,
890 endedAt: 0,
891 note: "",
892 });
893 }
894 return { title, gate, phases, keepThreads: input?.keep_threads === true || input?.keepThreads === true };
895}
896
897// The gate that decides what happens after phase i (its own, else the plan's).
898export function gateAfter(plan, i) {
899 return plan.phases[i]?.gate ?? plan.gate;
900}
901
902// What phase i is told: the whole plan, its own task and check, how to hand off,
903// and (from phase 2 on) the exact handoff it starts from.
904export function buildPhasePrompt(plan, i, { handoffPath, predecessorHandoff }) {
905 const ph = plan.phases[i];
906 const list = plan.phases
907 .map((p, j) => `${phaseNumber(j)} ${p.name} (${p.model}${p.effort ? `, effort ${p.effort}` : ""})${j === i ? " <- you" : ""}`)
908 .join("\n");
909 const lines = [];
910 if (i > 0 && predecessorHandoff) {
911 lines.push(
912 "Before anything else: run the /prime skill if it is available; either way make sure you have read this exact handoff file (read it directly if /prime is not available or picked another file):",
913 predecessorHandoff,
914 "Verify it against the current files, then confirm the recovered state in one line. Only then start your task.",
915 "",
916 );
917 }
918 lines.push(
919 `You are phase ${phaseNumber(i)} "${ph.name}" of the plan "${plan.title}" (plan ${plan.id}), which runs one phase at a time:`,
920 list,
921 "",
922 `Project root: ${plan.cwd}`,
923 "",
924 "Your task:",
925 ph.task,
926 );
927 if (ph.acceptance) lines.push("", "Acceptance check (run or verify it before you hand off):", ph.acceptance);
928 lines.push(
929 "",
930 "Finish by writing your handoff. This is required: the next phase starts from it.",
931 `1. Write a NEW file at exactly ${handoffPath} (never edit an existing handoff file). Sections: Project root, Accepted decisions, Changed files, Commands run, Check results, Known failures, Open questions, Exact next action.`,
932 `2. Overwrite ${plan.handoffDir}/LATEST.md with two lines: line 1 the handoff file name, line 2 a one-line summary of where this phase landed.`,
933 "3. If the project is a git repository and its .gitignore does not list handoff/, add that line.",
934 "Then end your turn with a short summary of what you did and the handoff path.",
935 );
936 return lines.join("\n");
937}
938
939export function revisionPrompt(feedback, handoffPath, plan) {
940 return [
941 `The lead asks for a revision of your phase: ${oneLine(feedback) || "address the acceptance check."}`,
942 `When done, write a NEW handoff file at exactly ${handoffPath} (do not edit the old one), update ${plan.handoffDir}/LATEST.md, and end your turn with a short summary.`,
943 ].join("\n");
944}
945
946export function handoffNudge(handoffPath, plan) {
947 return `You finished without writing the handoff file. Write it now at exactly ${handoffPath} (sections: Project root, Accepted decisions, Changed files, Commands run, Check results, Known failures, Open questions, Exact next action), update ${plan.handoffDir}/LATEST.md, then end your turn.`;
948}
949
950// The plan's state machine. Pure: takes the plan and one event, returns the next plan and
951// the actions the caller carries out. Events:
952// { type: "finished", index, hasHandoff } a phase thread went idle
953// { type: "decision", decision: "approve"|"revise", feedback } the lead's (or the person's) verdict on the awaiting phase
954// { type: "next" } /threads plan next (the user gate)
955// { type: "started", index, threadId, handoffPath, at } a phase thread was created
956// { type: "retry", index } start that phase again
957// { type: "stop" }
958export function stepPlan(plan, event) {
959 const p = { ...plan, phases: plan.phases.map((x) => ({ ...x })) };
960 const actions = [];
961 const cur = p.current ?? 0;
962 const ph = p.phases[cur];
963 // An accepted phase's thread closes, except the one just before the next phase, which stays
964 // until that phase has produced output (the watcher closes it then). keepThreads keeps all.
965 const startNext = (i) => {
966 if (!p.keepThreads && i - 2 >= 0) actions.push({ type: "retire", index: i - 2, reason: "its successor is under way" });
967 if (i >= p.phases.length) {
968 if (!p.keepThreads) actions.push({ type: "retire", index: i - 1, reason: "the plan is done" });
969 p.status = "done";
970 actions.push({ type: "done" });
971 } else {
972 p.current = i;
973 actions.push({ type: "start", index: i });
974 }
975 };
976 if (p.status === "stopped" || p.status === "done") {
977 if (event.type !== "retry") return { plan: p, actions: [{ type: "ignored", reason: `the plan is ${p.status}` }] };
978 }
979 switch (event.type) {
980 case "started": {
981 const t = p.phases[event.index];
982 t.status = "working";
983 t.threadId = event.threadId;
984 t.handoffPath = event.handoffPath;
985 t.startedAt = event.at;
986 t.note = "";
987 p.status = "running";
988 break;
989 }
990 case "finished": {
991 if (event.index !== cur || !ph || (ph.status !== "working" && ph.status !== "revising")) {
992 actions.push({ type: "ignored", reason: "not the phase that is running" });
993 break;
994 }
995 if (!event.hasHandoff) {
996 if (ph.nudges < 1) {
997 ph.nudges += 1;
998 ph.note = "finished without its handoff file; asked it to write one";
999 actions.push({ type: "nudge", index: cur });
1000 } else {
1001 ph.status = "blocked";
1002 ph.note = "finished twice without writing its handoff file";
1003 p.status = "blocked";
1004 actions.push({ type: "blocked", index: cur, reason: ph.note });
1005 }
1006 break;
1007 }
1008 const gate = gateAfter(p, cur);
1009 if (gate === "auto") {
1010 ph.status = "done";
1011 ph.endedAt = event.at ?? 0;
1012 startNext(cur + 1);
1013 } else {
1014 ph.status = "awaiting-gate";
1015 ph.note = gate === "lead" ? "waiting for the lead's review" : "waiting for /threads plan next";
1016 actions.push({ type: gate === "lead" ? "ask-lead" : "ask-user", index: cur });
1017 }
1018 break;
1019 }
1020 case "decision":
1021 case "next": {
1022 if (!ph || ph.status !== "awaiting-gate") {
1023 actions.push({ type: "ignored", reason: `phase ${phaseNumber(cur)} is ${ph?.status ?? "missing"}, not awaiting a gate` });
1024 break;
1025 }
1026 const decision = event.type === "next" ? "approve" : event.decision;
1027 if (decision === "approve") {
1028 ph.status = "done";
1029 ph.endedAt = event.at ?? 0;
1030 ph.note = "";
1031 startNext(cur + 1);
1032 } else if (decision === "revise") {
1033 if (ph.revisions >= MAX_REVISIONS) {
1034 ph.status = "blocked";
1035 ph.note = `still not accepted after ${MAX_REVISIONS} revisions`;
1036 p.status = "blocked";
1037 actions.push({ type: "blocked", index: cur, reason: ph.note });
1038 } else {
1039 ph.revisions += 1;
1040 ph.status = "revising";
1041 ph.note = `revision ${ph.revisions} of ${MAX_REVISIONS}`;
1042 actions.push({ type: "revise", index: cur, feedback: event.feedback ?? "" });
1043 }
1044 } else {
1045 actions.push({ type: "ignored", reason: `unknown decision "${decision}"` });
1046 }
1047 break;
1048 }
1049 case "retry": {
1050 const i = event.index;
1051 if (!p.phases[i]) {
1052 actions.push({ type: "ignored", reason: "no such phase" });
1053 break;
1054 }
1055 for (let j = i; j < p.phases.length; j++) {
1056 Object.assign(p.phases[j], { status: "queued", threadId: j === i ? p.phases[j].threadId : "", revisions: 0, nudges: 0, note: "" });
1057 }
1058 p.status = "running";
1059 p.current = i;
1060 actions.push({ type: "start", index: i });
1061 break;
1062 }
1063 case "stop": {
1064 p.status = "stopped";
1065 actions.push({ type: "stopped" });
1066 break;
1067 }
1068 default:
1069 actions.push({ type: "ignored", reason: `unknown event ${event.type}` });
1070 }
1071 if (actions.some((a) => a.type !== "ignored") || event.type === "started") {
1072 p.history = [...(p.history ?? []), historyEntry(event, actions, cur)].slice(-200);
1073 }
1074 return { plan: p, actions };
1075}
1076
1077function historyEntry(event, actions, cur) {
1078 const e = { at: event.at ?? 0, event: event.type, phase: (event.index ?? cur) + 1 };
1079 if (event.decision) e.decision = event.decision;
1080 if (event.feedback) e.feedback = oneLine(event.feedback).slice(0, 300);
1081 if (event.threadId) e.thread = event.threadId;
1082 if (event.hasHandoff !== undefined) e.hasHandoff = event.hasHandoff;
1083 e.then = actions.map((a) => a.type);
1084 return e;
1085}
1086
1087// `/threads plan start <title> [--gate g] [--cwd path] -- model[/effort] Name: task || model Name: task`
1088export function parsePlanCommand(args) {
1089 const text = String(args ?? "");
1090 const sep = /(^|\s)--(\s|$)/.exec(text);
1091 if (!sep) return { error: PLAN_USAGE };
1092 const tokens = tokenize(text.slice(0, sep.index));
1093 const body = text.slice(sep.index + sep[0].length).trim();
1094 const out = { title: "", gate: undefined, cwd: undefined, phases: [] };
1095 const words = [];
1096 for (let i = 0; i < tokens.length; i++) {
1097 if (tokens[i] === "--gate") out.gate = tokens[++i];
1098 else if (tokens[i] === "--mode") out.mode = tokens[++i];
1099 else if (tokens[i] === "--cwd") out.cwd = tokens[++i];
1100 else words.push(tokens[i]);
1101 }
1102 out.title = words.join(" ");
1103 for (const chunk of body.split("||").map((c) => c.trim()).filter(Boolean)) {
1104 const m = /^(\S+)\s+([^:]+):\s*([\s\S]+)$/.exec(chunk);
1105 if (!m) return { error: `Could not read the phase "${clip(chunk, 60)}". ${PLAN_USAGE}` };
1106 const [model, effort] = m[1].split("/");
1107 out.phases.push({ model, effort, name: m[2].trim(), task: m[3].trim(), ...(out.mode ? { permission_mode: out.mode } : {}) });
1108 }
1109 if (!out.title || out.phases.length === 0) return { error: PLAN_USAGE };
1110 return out;
1111}
1112
1113export const PLAN_USAGE =
1114 "Usage is /threads plan start <title> [--gate auto|lead|user] [--mode acceptEdits|...] [--cwd <path>] -- <model>[/<effort>] <Name>: <task> || <model> <Name>: <task> ...";
1115
1116export function planStatusText(plan, threadsById = new Map()) {
1117 const mark = { queued: "·", working: "●", "awaiting-gate": "◆", accepted: "✓", revising: "↻", blocked: "✕", done: "✓" };
1118 const lines = [`Plan ${plan.title} (${plan.id}) · ${plan.status} · gate ${plan.gate} · ${plan.cwd}`];
1119 plan.phases.forEach((ph, i) => {
1120 const t = threadsById.get(ph.threadId);
1121 const who = ph.threadId ? ` · ${ph.threadId}${t ? ` ${t.status}` : ""}` : "";
1122 const gate = ph.gate && ph.gate !== plan.gate ? ` · gate ${ph.gate}` : "";
1123 lines.push(`${mark[ph.status] ?? "?"} ${phaseNumber(i)} ${ph.name} · ${ph.model}${ph.effort ? `/${ph.effort}` : ""} · ${ph.status}${who}${gate}`);
1124 if (ph.handoffPath) lines.push(` handoff ${ph.handoffPath}`);
1125 if (ph.note) lines.push(` ${ph.note}`);
1126 });
1127 return lines.join("\n");
1128}
1129
1130// ---- cost (est. API-equivalent) ---------------------------------------------------------------
1131//
1132// List prices in USD per million tokens (task-budget's table). Cache writes: a row that says how
1133// many tokens went to the 1-hour and 5-minute caches is priced exactly (2x and 1.25x input);
1134// otherwise by loop, as task-budget measured: a main loop writes at the 1-hour rate, a subagent
1135// at the 5-minute rate.
1136
1137export const PRICES = [
1138 { match: /opus-5-5/, input: 4, output: 20, cacheRead: 0.2 },
1139 { match: /opus/, input: 5, output: 25, cacheRead: 0.5 },
1140 { match: /sonnet-5/, input: 2, output: 10, cacheRead: 0.2 },
1141 { match: /sonnet/, input: 3, output: 15, cacheRead: 0.3 },
1142 { match: /haiku/, input: 1, output: 5, cacheRead: 0.1 },
1143 { match: /fable-5-1|mythos-5-1/, input: 10, output: 50, cacheRead: 0.25 },
1144 { match: /fable|mythos/, input: 10, output: 50, cacheRead: 1 },
1145];
1146const WRITE_1H = 2;
1147const WRITE_5M = 1.25;
1148
1149export function priceOf(model) {
1150 const m = String(model ?? "").toLowerCase();
1151 return PRICES.find((x) => x.match.test(m)) ?? null;
1152}
1153
1154// One request's usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, cache_creation? }.
1155export function costOfUsage(usage, model, loop = "main") {
1156 const price = priceOf(model);
1157 if (!price || !usage) return { usd: 0, isPriced: Boolean(price) };
1158 const per = (n, rate) => ((Number(n) || 0) * rate) / 1e6;
1159 const split = usage.cache_creation;
1160 let writes;
1161 if (split && (split.ephemeral_1h_input_tokens !== undefined || split.ephemeral_5m_input_tokens !== undefined)) {
1162 writes = per(split.ephemeral_1h_input_tokens, price.input * WRITE_1H) + per(split.ephemeral_5m_input_tokens, price.input * WRITE_5M);
1163 } else {
1164 writes = per(usage.cache_creation_input_tokens, price.input * (loop === "main" ? WRITE_1H : WRITE_5M));
1165 }
1166 const usd = per(usage.input_tokens, price.input) + per(usage.output_tokens, price.output) + per(usage.cache_read_input_tokens, price.cacheRead) + writes;
1167 return { usd, isPriced: true };
1168}
1169
1170// A session thread's whole transcript: one charge per API message (its content blocks share one usage).
1171export function costFromTranscript(jsonl) {
1172 const seen = new Set();
1173 let usd = 0;
1174 let unpriced = 0;
1175 for (const raw of String(jsonl ?? "").split("\n")) {
1176 if (!raw.includes('"usage"')) continue;
1177 let row;
1178 try {
1179 row = JSON.parse(raw);
1180 } catch {
1181 continue;
1182 }
1183 if (row.type !== "assistant" || row.isSidechain || !row.message?.usage) continue;
1184 const key = row.message.id ?? row.requestId ?? row.uuid;
1185 if (key && seen.has(key)) continue;
1186 if (key) seen.add(key);
1187 const model = row.message.model;
1188 if (!model || String(model).startsWith("<")) continue;
1189 const c = costOfUsage(row.message.usage, model, "main");
1190 if (c.isPriced) usd += c.usd;
1191 else unpriced += 1;
1192 }
1193 return { usd, unpriced };
1194}
1195
1196export function money(usd) {
1197 const n = Number(usd) || 0;
1198 if (n === 0) return "$0";
1199 if (n < 0.01) return `$${n.toFixed(4)}`;
1200 if (n < 10) return `$${n.toFixed(3)}`;types/index.d.ts 106 lines1export type ThreadsReport = { at: number; text: string; source?: "peer" | "watcher" | "inline" };
2export type ThreadsRow = {
3 id: string;
4 backend?: "session" | "inline";
5 agentId?: string;
6 resolvedModel?: string;
7 title: string;
8 requestedModel: string;
9 verifiedModel: string;
10 sessionId: string;
11 tmux: string;
12 cwd: string;
13 permissionMode: string;
14 reportBack: boolean;
15 parent: { sessionId: string; title: string; socket: string };
16 task: string;
17 createdAt: number;
18 status: string;
19 lastReport: ThreadsReport | null;
20 bridgeSessionId: string;
21 closedAt: number;
22 endedAt?: number;
23 pid: number;
24 socket: string;
25 appendedKey?: string;
26 costUsd?: number;
27 pinned?: boolean;
28 archived?: boolean;
29 forkedFrom?: { sessionId: string; title: string; include: string; file: string };
30 handedOff?: boolean;
31 seenAt?: number;
32 worktree?: { name: string; repo: string; path: string; branch: string; base: string; launchCwd: string; removed?: boolean; kept?: boolean; commits?: number; dirty?: boolean };
33 adoptedAt?: number;
34 closedBy?: string;
35 effort?: string;
36 planId?: string;
37 phaseIndex?: number;
38 predecessorId?: string;
39 successorId?: string;
40 handoffPath?: string;
41 gate?: string;
42 acceptance?: string;
43 isMine: boolean;
44 lastLine: string;
45 lastKind: string;
46 prompt: string;
47};
48export type ThreadsActivity = { kind: string; text: string; at: number };
49export type ThreadsAgentMeta = { costUsd?: number; effort?: string; status?: string; needsYou?: boolean; model?: string; partial?: string; lastAt?: number };
50export type ThreadsPlanPhase = {
51 name: string;
52 model: string;
53 effort?: string;
54 task: string;
55 acceptance: string;
56 permissionMode?: string;
57 gate?: string;
58 status: string;
59 threadId: string;
60 handoffPath: string;
61 revisions: number;
62 nudges: number;
63 startedAt: number;
64 endedAt: number;
65 note: string;
66};
67export type ThreadsPlan = {
68 id: string;
69 title: string;
70 cwd: string;
71 gate: string;
72 keepThreads?: boolean;
73 handoffDir: string;
74 backend: string;
75 createdAt: number;
76 lead: { sessionId: string; title: string };
77 status: string;
78 current: number;
79 phases: ThreadsPlanPhase[];
80 history?: Array<{ at: number; event: string; phase: number; decision?: string; feedback?: string; thread?: string; hasHandoff?: boolean; then: string[] }>;
81};
82export type ThreadsView = { plans?: ThreadsPlan[]; threads: ThreadsRow[]; selfId: string; leadTitle: string; refreshedAt: number; cap: number };
83export type ThreadsUi = { selected: string; mode: "transcript" | "screen"; showOthers: boolean; steering: boolean; notice: string; planView?: boolean; picker?: string; armedClose?: { id: string; at: number } | null; showArchived?: boolean; planIndex?: number; renaming?: boolean };
84export type ThreadsDetail = { id: string; mode: "transcript" | "screen"; lines: string[]; at: number };
85
86declare module "claude-code" {
87 interface PluginState {
88 threads: {
89 view: ThreadsView;
90 ui: ThreadsUi;
91 detail: ThreadsDetail;
92 auth: { at: number; loggedIn: boolean; detail: string };
93 inline: Record<string, string>;
94 activity: Record<string, ThreadsActivity[]>;
95 agents: Record<string, ThreadsAgentMeta>;
96 watch: Record<string, string>;
97 sent: Record<string, { text: string; at: number }>;
98 wakes: Record<string, { key: string; kind: string; planId?: string; index?: number; threadId?: string; text: string; at: number }>;
99 lead: { busy?: boolean };
100 setupRan: { at: number; passed: boolean; summary: string };
101 cost: Record<string, { size: number; usd: number }>;
102 surface: string;
103 };
104 }
105}
106