SLOPSHOPPER

threads

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

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · threads
│ ┃ Threads ✕ › fix the failing auth test and add an audit log call │ ┃ Threads · 0 yours · 01:53:20 [ r Refresh ] [ │ ┃ Click a thread or any button. Keys: ctrl+x ⏺ Read(src/auth.ts) │ ┃ then tab gives the pane the keyboard; the ⎿ Read 6 lines │ ┃ letter on each button is its key. ⏺ Update(src/auth.ts) │ ┃ Lead · chat preview- ⎿ Added 2 lines, removed 1 line │ ┃ no threads yet: /threads new ⏺ Bash(bun test) │ ┃ 0 live · cap 4 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Create one with /threads new haiku Scout --… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ or ask in plain English: spin up a Haiku th… │ ✻ Worked for 42s · done 4:20 PM │ │ › /threads │ ⎿ threads: Threads pane open: 0 live threads of this chat. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Threads
Threads · 0 yours · 01:53:20 [ r Refresh ] [ c Close ] Click a thread or any button. Keys: ctrl+x then tab gives the pane the keyboard; the letter on each button is its key. Lead · chat preview- no threads yet: /threads new 0 live · cap 4 Create one with /threads new haiku Scout -- list the fi… or ask in plain English: spin up a Haiku thread to ...
README

Early AI-dopters · Gumroad Resource Vault

Every free resource from the Early AI-dopters Gumroad, mirrored here and kept in sync. Each folder holds the actual files (zips unpacked so you can browse them on GitHub), the resource content page, and a README. Where a resource came from a YouTube video, the video is paired to it. Watch or star this repo to catch every new drop.

180 published resources, grouped by topic and newest-first within each. Sync scheduled hourly. See the latest verified check below.

<!-- sync-status:start --> Last verified sync: October 09, 2026 at 05:12 AM EDT · 180/180 published resources accounted for; 313 mirrored attachments verified. Oversized attachments available on Gumroad: 1. Sync details.

<!-- sync-status:end -->

🆕 Latest 10 drops

The newest resources with their videos. Updates automatically on every sync.

ResourceWatchGet it
Jev vs OpenAI Decisions: Results + Setup Guide-Gumroad
<a href="https://youtu.be/UcvD53jqHRU"><img src="https://i.ytimg.com/vi/UcvD53jqHRU/maxresdefault.jpg" width="200"></a>Two Mods for Claude Code: Computer Use + Threads▶ WatchGumroad
<a href="https://youtu.be/uvIopT_2sY0"><img src="https://i.ytimg.com/vi/uvIopT_2sY0/maxresdefault.jpg" width="200"></a>Claude Mods Starter Kit: 10 Mods + Build-Your-Own Guide▶ WatchGumroad
<a href="https://markkashef.gumroad.com/l/wisprclone"><img src="https://public-files.gumroad.com/q6j0hn2ue1kx8c4yedfxltj6ctiq" width="200"></a>WisprFlow Clone: Mac App Source + Build Prompts-Gumroad
<a href="https://markkashef.gumroad.com/l/use-both-claude-codex-workflow-kit"><img src="https://public-files.gumroad.com/lpvokrqflyo89ahmuqj6b9udw1b3" width="200"></a>Use Both: Claude + Codex Workflow Kit-Gumroad
<a href="https://youtu.be/z8My0bX2-ZU"><img src="https://i.ytimg.com/vi/z8My0bX2-ZU/maxresdefault.jpg" width="200"></a>Your Own Jev: Prompt, Guide & Local AI Starter Kit▶ WatchGumroad
<a href="https://youtu.be/zZNm4zP_lEE"><img src="https://i.ytimg.com/vi/zZNm4zP_lEE/maxresdefault.jpg" width="200"></a>Jev Starter Kit: Explainer + Live Playground▶ WatchGumroad
<a href="https://youtu.be/575v0WYJQsE"><img src="https://i.ytimg.com/vi/575v0WYJQsE/maxresdefault.jpg" width="200"></a>Codex + Ollama Setup Kit▶ WatchGumroad
<a href="https://markkashef.gumroad.com/l/sf-ig-frontend-design-loop"><img src="https://public-files.gumroad.com/v3ormgouaorqmqdyfdbc5khgkis4" width="200"></a>Frontend Design & Feedback Kit-Gumroad
<a href="https://markkashef.gumroad.com/l/sf-ig-voice-qualification"><img src="https://public-files.gumroad.com/5gvxgzzd9otxkkqu5gs3kiyhlc3b" width="200"></a>Voice AI Qualification Checklist-Gumroad

Categories

🎯 Prompting & Engineering Skills

ResourceWhat it isVideoGet it
Dynamic Workflows Prompt Pack 🎛️Most people use Claude Code like a chatbot.▶ WatchGumroad
Polyskill Kit 🧩The Polyskill Meta Skill: A ready to install skill workspace.▶ WatchGumroad
The Skill Audit Prompt 🔍The Skill Audit Prompt: A copy paste prompt that tags the claude code guide subagent and runs a full structural review on any Claude Code skill you…▶ WatchGumroad
Claude Code Monthly Insights Skill 🛠️Monthly Insights Skill for Claude Code: A drop in skill that turns /insights into a full monthly review system.▶ WatchGumroad
One-Shot Skill Creation Prompt 🎥One Shot Skill Prompt: The exact polished prompt from the video to extract a comprehensive SOP from any screen recording using Gemini Video…▶ WatchGumroad
Claude Code Guide Prompts ⭐️5 Level Prompt Blueprint: Copy paste prompts for every level of @claude code guide — from asking a basic question to having Claude fix its own broken…▶ WatchGumroad
The Council Skill for Claude Code 👑The Council Skill (.skill file): Drop it into Claude Code and immediately tap into OpenAI and Google models without leaving your terminal.▶ WatchGumroad
ClaudeClaw - Mega Prompt + Visual Guide 🦞1.▶ WatchGumroad
Agent Team Prompts for Claude Code 👥 👥7 Copy Paste Agent Team Prompts: Ready to use prompts that spawn multi agent teams in Claude Code for real business tasks not coding exercises.▶ WatchGumroad
Claude Code Skills Guide 📓 🤖Skill Examples (Bad vs Good): Two complete bad skills and two complete good skills side by side.▶ WatchGumroad
ChatGPT Atlas → n8n Builder Prompt ⚙️A copy paste Atlas Agent Mode meta prompt that clicks n8n “Build with AI”, drafts nodes, configures settings, injects an LLM system prompt (<800…▶ WatchGumroad
Claude Skills Factory Prompt 🏭Claude Code ready master prompt you can copy paste to scaffold complete skill folders, SKILL.md, sample prompt.md, and ZIP packaging for immediate…▶ WatchGumroad
ChatGPT Thinking Modes Analysis 🧠Full breakdown of ChatGPT’s new Light, Standard, Extended, Heavy thinking levels, mapped against speed, style, and best fit use cases.▶ WatchGumroad
Emoji Prompting Pack 🎨🤖11 minute video showing where emoji prompts shine (clarity, speed, structure) and where they fail (bloat, misreads).▶ WatchGumroad
Universal AI Browser Prompts 🌐Copy paste Quick Start Template with all fields pre wired: PERSONA, AIM, RECIPIENTS, THEME, CONTEXT, TASK, VERIFICATION, OUTPUT, SOURCES.▶ WatchGumroad
Model Migration Master Prompt 🔄A refined master prompt that instantly compares any two AI models and gives you a step by step migration guide.▶ WatchGumroad
Agent Mode Vibe Coding 🔮A masterfully crafted prompt for lovable.dev, guiding an AI agent to autonomously build, refine, and troubleshoot a visually stunning, life changing…▶ WatchGumroad
Abacus AI Plug & Play Prompts 🤖10 ready to use, high impact prompts designed for instant deployment with Abacus AI.▶ WatchGumroad
Agent Mode Prompt Pack 🧠📋10 real world Agent Mode prompts copy pasted directly from the video—no filler, just the exact text that worked.▶ WatchGumroad
Scrape Anything with Apify MCP ConnectorReady to Deploy MCP Config Schema: A clean JSON schema preconfigured with Apify’s top scrapers—YouTube, TikTok, Instagram, Google Maps, X (Twitter)…▶ WatchGumroad
OpenAI Operator Sample Prompt 💻🌐Exact prompt blueprint for one n8n two phase workflow (Gmail trigger → OpenAI model calls → Gmail send) Step by step instructions to configure Gmail…▶ WatchGumroad
Perplexity Labs Prompts 🥼 🔬Five fully detailed, ready to use prompts tailored specifically for Perplexity Labs, enabling deep analysis of AI, robotics, AGI impacts, and…▶ WatchGumroad
GenSpark Prompt Vault (7 Examples) 🐣A curated set of high performance prompts optimized for GenSpark’s multi tool AI automation suite.▶ WatchGumroad
Gemini Vibe Coding Prompts 😎 💖Ready to use initial prompt optimized specifically for Gemini 2.5 Pro, ensuring precision and reducing guesswork.▶ WatchGumroad
Ultimate o3 Prompt Bundle 🔋10 expertly crafted prompts tailored for leveraging OpenAI’s advanced o3 model.▶ WatchGumroad
4o Image Generation Suite 🖼️A detailed Knowledge Base file loaded with expert guidelines, examples, and best practices for creating precise prompts that seamlessly replace…▶ WatchGumroad
n8n Prompt Engineer Workflow ✏️Comprehensive Prompt Engineering Workflows: Ready to use n8n workflows specifically optimized for OpenAI, Google Gemini, Anthropic Claude, and other…▶ WatchGumroad
Deep Research Prompt Examples 📖What You’ll Get: In Depth Notion Guide Sample inspiration prompts covering real world use cases—from vendor selection to cross language research—so…▶ WatchGumroad
ChatGPT Task Prompting Guide + Custom GPT 🤖✏️Discover a new feature in ChatGPT that can help you be that much more productive — ChatGPT Tasks.▶ WatchGumroad
Prompt Engineer Anything ✏️ 📸 🎬 🎵You'll be able to access the meta prompts I demonstrated in the video, to help give you a kickstart to your prompt engineering in the following…▶ WatchGumroad
Sora Zero-to-Hero Prompting Guide 🎬Comprehensive Sora Prompting Guide: A detailed breakdown of shot types, camera movements, lighting techniques, and advanced descriptors to create…▶ WatchGumroad
Prompt Compression Guide 🗂️With the Prompt Compression Guide, you’ll gain access to a comprehensive set of experimental tools and resources designed to help you explore prompt…▶ WatchGumroad
Blended Persona PromptStep into the shoes of a renowned speaker, blending the directness and passion of Gordon Ramsay, the empathy and emotional depth of Oprah Winfrey…▶ WatchGumroad
Sample Prompt Engineer Validation PromptStep into the role of a prompt engineer and master the art of creating detailed, effective, and succinct prompts that lead to precise outcomes.▶ WatchGumroad
DNA Extraction PromptUnlock the art of replicating any writing style with precision.▶ WatchGumroad
Prompt Battle Testing (Level 1 to Level 4)Unlock the secrets of effective AI prompt engineering with this comprehensive Prompt Battle Testing toolkit.▶ WatchGumroad
Temperature & Top P Prompt + Google Sheet + SlidesTemperature vs.▶ WatchGumroad

🔌 Automation & Workflows (n8n, Replit, Make.com)

ResourceWhat it isVideoGet it
Master Canvas Guide for n8n Vibe AutomationImport ready n8n workflow JSON covering core triggers (Manual, Schedule, Webhook, Google Sheets), path splitters (If/Switch/Merge/Wait), and AI…▶ WatchGumroad
Claude Skills in n8n Workflow Template 🍳Import ready n8n template (Anthropic Agent Skills Workflows.json) for PPTX, PDF, DOCX, XLSX.▶ WatchGumroad
Claude Agent Skills Pack 🧰CSV → Slides automator: a ready to run skill that turns spreadsheets into presentation ready PowerPoints (tables + charts).▶ WatchGumroad
Agent Builder Starter Clone Kit 🧩Visual Workflow Builder Spec: full stack architecture, six node canvas (Start/Agent/Tool/Router/Transform/End), OpenAI Responses API integration (GPT…▶ WatchGumroad
Claude Code Agentic Architect 🧱A single, plug and play prompt that analyzes any scenario and designs a complete 1–3 agent system end to end.▶ WatchGumroad
n8n Data Tables Kit 🧠Import ready n8n workflow (.json) with three scenarios: Sheet→Table sync, AI Sales analysis, and a Sheets vs Data Tables speed test.▶ WatchGumroad
FULL Agentic Patterns Kit 🧠Direct link to the Agentic Design Patterns README with all 20+ patterns (Core, Advanced, System, Optimization, Strategic).▶ WatchGumroad
Anti-Shiny-Object Coach 🧭A custom GPT that cuts through tool overwhelm and gives 2–3 clear next steps.▶ WatchGumroad
Base44 & n8n Presentation Generator 🖼️A ready to import n8n workflow that connects directly to Gamma and auto generates professional slide decks in under 2 minutes.▶ WatchGumroad
n8n Text-to-Workflow Prompts ⚡️3 ready to copy prompts built for n8n’s new Text to Workflow: Lead Qualification, Mock Calendly Webhook JSON, and Fireflies Transcript → Nurture…▶ WatchGumroad
AI Model Comparison Automation ⚖️Level 1 Workflow JSON (Workflow 1 of 2): Import ready n8n file that runs on a schedule for hands free model tests—its matching Judge workflow lives…▶ WatchGumroad
MCP Workflow Power Bundle 🤖 ☢️Level 1 Ready to Run Docs: Step by step Claude Desktop + Remote MCP Server setup documentation—no guesswork, just follow along and launch.▶ WatchGumroad
Meeting Prep n8n Workflow 🗓️ ➡️ 🎧A plug and play n8n workflow that transforms Calendly bookings into personalized audio briefings.▶ WatchGumroad
X Live Search Vibe Code Pack 🐦A comprehensive Cheat Sheet Guide (Google Doc) detailing every prompt and step to build the UI foundation, deploy a Supabase edge function, and…▶ WatchGumroad
Promptify Chrome Extension ⚙️Ready to run Chrome extension for instant prompt injection into Windsurf, Replit, Bolt.new, Lovable, and more.▶ WatchGumroad
Vibe Coding Starter Pack 🔥Four pro PDF cheat sheets for Lovable 2.0, Bolt + Stripe, Replit Agent v2, and Cursor AI—perfect side kicks while you watch the free YouTube…▶ WatchGumroad
OpenAI Image API n8n Agents 🤖 🖼️A complete n8n automation bundle to generate and edit high quality images using OpenAI's new Image API (gpt image 1, dall e 3, and dall e 2…▶ WatchGumroad
Manus AI n8n Automation Pack 🤖 🫰🏽3 comprehensive sets of n8n workflow automations (10 automations per set), fully created by the autonomous Manus AI system.▶ WatchGumroad
Claude Project to n8n Workflow 🤖A complete prompt and setup guide to transform Claude project instructions into valid n8n JSON—no screenshots, no fluff, just working workflows.▶ WatchGumroad
Easy Fine-Tuning Automation ⚙️All In One JSONL Prompt A specialized prompt that auto generates exactly 50 training samples in JSONL format, perfectly tailored to your brand voice…▶ WatchGumroad
n8n Agent-to-Agent Workflow 🤖What You’ll Get Main AI Agent Creator Flow A powerful n8n workflow that dynamically builds AI agents on demand A secondary workflow that handles in…▶ WatchGumroad
Make.com to n8n Converter Automation 🔁What You’ll Get n8n Workflow Converter – A pre built n8n workflow that automatically ingests and converts Make.com JSON files.▶ WatchGumroad
Prompt-to-Agent n8n Guide 🦾What You’ll Get High Performance Prompt for Deep Research The exact prompt used in the video, proven to yield the best results when generating n8n…▶ WatchGumroad
Lovable Suite for AI-Powered App BuildingWhat You’ll Get: Lovable Prompt Engineer Assistant A comprehensive, shell first guide with codeblock outputs that walks you through framing your…▶ WatchGumroad

| [Email Automations U

Source 3 files
hooks/threads.mjs 3772 lines
1// 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 lines
1// 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 lines
1export 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