thinkpool-pair 0.7.364 → 0.7.366

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
@@ -1,1519 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- claude-session.mjs — a structured, interactive Claude Code session
3
- for the ThinkPool bridge. Wraps @anthropic-ai/claude-agent-sdk:
4
- one long-lived streaming-input query() per terminal, structured
5
- events out, user turns + abort in, and a PreToolUse permission gate
6
- that classifies each tool call's risk and round-trips the decision
7
- to the room (the risk-tiered permission card).
8
-
9
- This replaces the PTY byte relay for Claude Code only. Other CLIs
10
- keep the node-pty path in bridge.mjs. Auth is the HOST's own Claude
11
- Code login (Keychain / API key) — no ThinkPool credential involved.
12
- Spec: docs/specs/2026-06-11-code-structured-reader.md
13
- ───────────────────────────────────────────────────────────── */
14
-
15
- import { randomUUID } from 'node:crypto'
16
- import { createRequire } from 'node:module'
17
- import { readFileSync } from 'node:fs'
18
- import { dirname, join } from 'node:path'
19
- import { query } from '@anthropic-ai/claude-agent-sdk'
20
- import { sanitizeSession } from './transcript-sanitize.mjs'
21
- import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
22
- import { crossPostNeedsCard } from './cross-terminal.mjs'
23
- import { correctContext } from './context-windows.mjs'
24
- import { normalizeClaudeCommandCatalog } from './claude-command-catalog.mjs'
25
- import { evidenceForToolResult } from './evidence-citations.mjs'
26
- import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
27
- import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
28
- import { createSameTargetResilienceController, formatResilienceTrace } from './provider-resilience.mjs'
29
-
30
- // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
31
- // once at import by walking up from the package entry to its own package.json.
32
- const req = createRequire(import.meta.url)
33
-
34
- // Shared raw one-shot for tiny bridge-owned inference jobs. No settings, skills,
35
- // MCP servers, or repo instructions are loaded; callers provide the model + prompt.
36
- export async function claudeOneShot({ prompt, model = 'claude-haiku-4-5', cwd, env, timeoutMs = 8000 } = {}) {
37
- const abortController = new AbortController()
38
- const timer = setTimeout(() => { try { abortController.abort() } catch { /* noop */ } }, timeoutMs)
39
- try {
40
- const result = query({
41
- prompt,
42
- options: {
43
- model,
44
- ...(cwd ? { cwd } : {}),
45
- env,
46
- maxTurns: 1,
47
- permissionMode: 'bypassPermissions',
48
- settingSources: [],
49
- strictMcpConfig: true,
50
- mcpServers: {},
51
- abortController,
52
- },
53
- })
54
- let out = ''
55
- for await (const message of result) {
56
- if (message.type === 'assistant') {
57
- for (const block of (message.message?.content || [])) if (block.type === 'text') out += block.text
58
- }
59
- if (message.type === 'result') break
60
- }
61
- return out.trim()
62
- } finally { clearTimeout(timer) }
63
- }
64
-
65
- // Named in the [SDK-REGRESSION] guard below so a silent gate-change is attributable.
66
- const SDK_VERSION = (() => {
67
- try {
68
- let d = dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
69
- for (let i = 0; i < 8; i++) {
70
- try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
71
- const up = dirname(d); if (up === d) break; d = up
72
- }
73
- } catch { /* unresolved */ }
74
- return 'unknown'
75
- })()
76
-
77
- // ── risk classification — the accent/danger tier of the permission card ──
78
- // low (read-only) · medium (writes/runs) · network (leaves the machine) ·
79
- // high (destructive, deny-first). See the permission spec + mockups.
80
- // Any `rm`/`rmdir` with an argument is destructive (a bare `rm NOTES.md`
81
- // deletes just as permanently as `rm -rf`). Plus force-push, hard reset,
82
- // clean -f, DROP, mkfs/dd, sudo, /dev redirects, recursive chmod/chown, etc.
83
- const DESTRUCTIVE = /\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
84
- const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'NotebookRead', 'TodoRead', 'LS'])
85
- const NETWORK_TOOLS = new Set(['WebFetch', 'WebSearch'])
86
- const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'TodoWrite'])
87
-
88
- export function classifyRisk(toolName, input) {
89
- if (toolName === 'Bash') {
90
- const cmd = (input && (input.command ?? input.cmd)) || ''
91
- return DESTRUCTIVE.test(cmd) ? 'high' : 'medium'
92
- }
93
- if (READONLY_TOOLS.has(toolName)) return 'low'
94
- if (NETWORK_TOOLS.has(toolName)) return 'network'
95
- if (WRITE_TOOLS.has(toolName)) return 'medium'
96
- // Unknown / MCP / Task tools: treat as medium (asks, amber) rather than
97
- // silently allowing — safer default for a remote-driven agent.
98
- return 'medium'
99
- }
100
-
101
- // ── safe-doc writes — auto-allow regardless of permission mode ──
102
- // The repo MANDATES end-of-session writes (devlogs under .claude/SESSIONS/, and
103
- // CLAUDE.md updates). They're append-only documentation with no runtime blast
104
- // radius. Carding them in `default` mode dead-ended a phone-driven paired session
105
- // (2026-06-15 SESSIONS-gate: every write threw a room card, deny-default + the
106
- // "do not retry" deny-reason made Claude abandon the write and re-explain). These
107
- // paths skip the card always; Bash/network/destructive/other writes are unchanged.
108
- // Spec: docs/specs/2026-06-15-paired-permission-safe-doc-writes.md.
109
- const SAFE_DOC_RE = /(^|\/)\.claude\/SESSIONS\/|(^|\/)CLAUDE\.md$/
110
- export function isSafeDocWrite(toolName, input) {
111
- if (!WRITE_TOOLS.has(toolName)) return false
112
- const p = (input && (input.file_path || input.notebook_path)) || ''
113
- return SAFE_DOC_RE.test(p)
114
- }
115
-
116
- // The full auto-allow decision (exported for the contract test, so the policy
117
- // can't drift away from docs/specs/2026-06-15-paired-permission-safe-doc-writes.md).
118
- // Mirrors the PreToolUse policy: reads always; bypass mode always; acceptEdits for
119
- // non-high writes; mandated safe-doc writes always; per tool:risk "always allow".
120
- export function autoAllow({ toolName, input, mode = 'default', alwaysAllow = new Set() }) {
121
- const risk = classifyRisk(toolName, input)
122
- return (
123
- risk === 'low' ||
124
- mode === 'bypassPermissions' ||
125
- (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high') ||
126
- isSafeDocWrite(toolName, input) ||
127
- alwaysAllow.has(`${toolName}:${risk}`)
128
- )
129
- }
130
-
131
- // AskUserQuestion answer path — pure mapping from the room's requestPermission
132
- // result to the PreToolUse decision fed back to the model. The card handler
133
- // denies (PreToolUse can't inject a tool_result) and puts the human's pick in
134
- // the deny reason, which IS what the model receives. Exported so the feedback
135
- // contract is locked in a unit test (mock requestPermission → this output).
136
- // `decision` is the requestPermission return: either the legacy 'answer:<pick>'
137
- // string or { decision, responder } from the durable room control. Anything else
138
- // (including '' / dismissal / a broken path) is treated as "no selection".
139
- export function askUserQuestionHookOutput(decision) {
140
- const rawDecision = decision && typeof decision === 'object' ? decision.decision : decision
141
- const ans = (typeof rawDecision === 'string' && rawDecision.startsWith('answer:')) ? rawDecision.slice(7) : ''
142
- const responder = decision && typeof decision === 'object' ? decision.responder : null
143
- const answeredBy = typeof responder?.name === 'string' && responder.name.trim()
144
- ? responder.name.trim()
145
- : typeof responder?.id === 'string' && responder.id
146
- ? `room member ${responder.id}`
147
- : 'The user'
148
- return {
149
- continue: true,
150
- hookSpecificOutput: {
151
- hookEventName: 'PreToolUse',
152
- permissionDecision: 'deny',
153
- permissionDecisionReason: ans
154
- ? `${answeredBy} answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
155
- : 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
156
- },
157
- }
158
- }
159
-
160
- // Regression guard for the AskUserQuestion gate. SDK 0.3.173 enabled the tool
161
- // unconditionally; a later bump in the 0.3.185→0.3.204 window flipped it to require
162
- // a `canUseTool` callback, and the ^0.3.x caret silently killed the feature on a
163
- // routine restart (2026-07-08). The bridge DOES pass canUseTool, so the SDK's init
164
- // `tools[]` MUST advertise AskUserQuestion — checked against the real init message
165
- // the live session already receives (no boot-time probe: cold-CLI init is flaky).
166
- // If the message carries a tools[] that lacks AskUserQuestion → return the loud
167
- // [SDK-REGRESSION] reason (logged + surfaced in the room). If it carries no tools[]
168
- // (older/other SDK message shape) → fail OPEN (null), never a false alarm. Pure +
169
- // exported so both branches are locked by a unit test.
170
- export function askUserQuestionRegression(tools, version) {
171
- if (!Array.isArray(tools)) return null
172
- if (tools.includes('AskUserQuestion')) return null
173
- return `[SDK-REGRESSION] agent SDK v${version || 'unknown'} init tools[] is missing 'AskUserQuestion' — the multiple-choice question card is disabled. A caret SDK bump likely changed the canUseTool gate; pin a known-good SDK in bridge/package.json + republish.`
174
- }
175
-
176
- // ── input stream — a generator we keep open and feed turns into ──
177
- function makeInputStream() {
178
- const queue = []
179
- let wake = null
180
- let ended = false
181
- async function* gen() {
182
- while (!ended) {
183
- if (queue.length) { yield queue.shift(); continue }
184
- await new Promise((r) => { wake = r })
185
- }
186
- }
187
- return {
188
- stream: gen(),
189
- push(content) {
190
- if (ended) return false // session ended — caller (sendTurn) can surface this
191
- queue.push({ type: 'user', message: { role: 'user', content } })
192
- if (wake) { wake(); wake = null }
193
- return true
194
- },
195
- end() { ended = true; if (wake) { wake(); wake = null } },
196
- }
197
- }
198
-
199
- // Simplify SDK assistant content blocks to a stable wire shape.
200
- const simplifyBlocks = (blocks = []) => blocks.map((b) => {
201
- if (b.type === 'text') return { type: 'text', text: b.text }
202
- if (b.type === 'thinking') return { type: 'thinking', text: b.thinking || '', reasoningKind: 'thought' }
203
- if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input }
204
- return { type: b.type }
205
- }).filter(Boolean)
206
-
207
- /**
208
- * Start a structured Claude Code session.
209
- *
210
- * @param {object} o
211
- * @param {string=} o.cwd working directory for the agent
212
- * @param {string=} o.model model id (default: host's configured)
213
- * @param {string=} o.resume session id to resume
214
- * @param {object=} o.env full environment for the agent subprocess
215
- * (REPLACES process.env in the child — spread process.env yourself).
216
- * Used to hand each session its own TP_MOCKUP_OUTBOX so mockup cards
217
- * attribute to the session that generated them, not a Map-order guess.
218
- * @param {(evt)=>void} o.onEvent receives normalized structured events
219
- * @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
220
- * called for EVERY tool call with { id, toolName, input, risk };
221
- * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
222
- * @param {boolean=} o.suggest allow the Haiku composer-prefill fallback.
223
- * MUST be false on a lane running a custom provider — see haikuSuggest.
224
- * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
225
- */
226
- const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
227
-
228
- // Periodic salience reminder. The full ThinkPool Code ruleset lives in
229
- // `appendSystemPrompt` (baked once at session start), but on a long session — heavy
230
- // tool output, and especially after auto-compaction — the model's attention drifts
231
- // off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
232
- // the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
233
- // or narrates instead of showing, forgetting it's driven from a phone. So we re-state
234
- // the highest-drift rules in a <system-reminder> at session start, after recovery,
235
- // and every fifth prompt. Ordinary turns get only the compact invariant and any
236
- // route-specific guidance implied by the request.
237
- // adjacent to where the model's attention actually is — the same trick the host
238
- // harness uses to keep CLAUDE.md alive. The shared capability router deliberately
239
- // A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended when it
240
- // changes and on the same periodic refresh cadence.
241
- const TP_ROOM_REMINDER = [
242
- 'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
243
- THINKPOOL_RUNTIME_TURN_REMINDER,
244
- 'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
245
- 'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
246
- 'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A person-requested new or separate terminal uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
247
- ].join(' ')
248
-
249
- export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null, admitStart = null, resilienceObserver = null, resilience = null }) {
250
- // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
251
- // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
252
- // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
253
- // the room's STATE (2026-07-02 ask: "aware at all points that it's in a ThinkPool
254
- // Code session"). A broken snapshot must never break a turn — fail-quiet to static.
255
- const selectRoomContext = createRoomContextSelector(roomContext)
256
- let userPromptNo = 0
257
- let forceFullReminder = true
258
- const roomReminder = (text, { promptIndex = 0, forceFull = false } = {}) => {
259
- const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
260
- const live = selectRoomContext({ force: fullReminder })
261
- const role = String(terminalRolePrompt || '').trim()
262
- const guidance = fullReminder ? TP_ROOM_REMINDER : buildThinkPoolTurnGuidance({ text, promptIndex, forceFull })
263
- return `<system-reminder>\n${fullReminder && role ? `${role}\n\n` : ''}${guidance}${live ? `\n\n${live}` : ''}\n</system-reminder>`
264
- }
265
- const ac = new AbortController()
266
- let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
267
- let sessionId = resume || null
268
- // persistedSessionId — the newest session id we KNOW is written to disk (the resume we
269
- // were handed, then advanced on each successful result). A model-switch re-create must
270
- // resume THIS, never the live `sessionId`: a resumed query can fork to a fresh session id
271
- // that isn't persisted until a turn completes, so re-creating on it fails with
272
- // "No conversation found" (Max 2026-07-02). This is always a resumable id.
273
- let persistedSessionId = resume || null
274
- let lastTurnText = null // the most recent turn text, so a bad-resume recovery can re-deliver it
275
- let lastTurnReminder = null
276
- let lastTurnBlocks = null // exact current prompt blocks; same-target retry must replay byte-for-byte
277
- let closed = false
278
- // Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
279
- // defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
280
- // FIRST turn — so a bridge restart restores every tab's transcript instantly and only
281
- // the terminals a person actually uses pay the boot. Mid-turn terminals boot eagerly
282
- // (lazy:false) so auto-resume still works.
283
- let started = false
284
- // Cold-start measurement (2026-06-21, spec 2026-06-21-code-session-deploy-stability):
285
- // stamp when the open begins so we can log spawn→ready (the SDK's first `init`
286
- // system message — when the terminal becomes usable). New room terminals "take a
287
- // long time"; settingSources:['user','project','local'] boots every host MCP
288
- // server + SessionStart hook + plugin before init. This is the A-side baseline;
289
- // open a terminal with TP_MCP_STRICT=1 (below) for the B-side to isolate MCP's share.
290
- // A lazy lane may sit unopened for hours. Start the latency clock when query() really
291
- // launches, not when the lightweight tab/session wrapper is created.
292
- let spawnT0 = null
293
- let readyLogged = false
294
- let modelsSent = false // one-shot: emit the SDK's supported-model list on first init
295
- let sdkToolsChecked = false // one-shot: run the AskUserQuestion regression guard on the first init carrying tools[]
296
- let q = null // the live Query — control requests (interrupt /
297
- // setPermissionMode) route through it once streaming.
298
- // The session is CREATED in the caller's chosen mode (not hard-coded default):
299
- // a bypass session must start in bypass so the agent never enters plan mode and
300
- // never throws an ExitPlanMode card. Falls back to 'default' for unknown values.
301
- let mode = MODES.has(initialMode) ? initialMode : 'default' // mirrors ⇧⇥ cycle
302
- const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
303
- const toolStart = new Map() // tool_use id → source metadata for duration/evidence
304
- const effortLevels = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
305
- let effort = effortLevels.has(initialEffort) ? initialEffort : 'high'
306
- // Live token count for the thinking indicator — mirrors Claude Code's
307
- // "↓ N tokens": the turn's billed OUTPUT tokens (thinking is billed as
308
- // output, so this is the full count, not just the reasoning estimate). A
309
- // tool-using turn emits several assistant messages; message_delta carries
310
- // the running output_tokens for the CURRENT message, so we fold finished
311
- // messages into turnBaseOut and add the live message's count on top. Reset
312
- // per turn on `result`.
313
- let turnBaseOut = 0 // output tokens from completed messages this turn
314
- let curMsgOut = 0 // latest output_tokens for the in-flight message
315
- let curModel = model || null // latest model id seen (init/system + ctx); stamped on
316
- // the result event so the bridge can attribute BYOK Code
317
- // spend per-model (record_code_usage → chat_usage).
318
- // A model switch on a RESUMED session can't be done live — q.setModel() RESOLVES without
319
- // error but is a silent no-op there (the turn keeps running the old model). So a switch is
320
- // applied by RE-CREATING the query resuming the same session on the new model (which
321
- // query({resume, model}) honours). Root of Max's 2026-07-02 "reverts to Opus at start of
322
- // turn": every terminal is a resumed session after a restart, so live setModel did nothing.
323
- // qAc = the CURRENT query's own AbortController (child of the session `ac`) so the
324
- // switch can kill JUST this query without ending the session; qDone resolves when the
325
- // current runQuery loop has fully exited (its process dead) so the re-create doesn't
326
- // race the old process on the session file lock (proven necessary 2026-07-02).
327
- // pendingSwitch defers a mid-turn switch until the active turn finishes.
328
- let qAc = null
329
- let qDoneResolve = null
330
- let qDone = Promise.resolve()
331
- let pendingSwitch = false
332
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
333
- // ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
334
- // 3+ min) or abort silently with no `result`. Track turn liveness + last event
335
- // time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
336
- // event so the room can stop showing a frozen "working" forever. Cleared on the
337
- // next event / `result`. Refs: TS SDK #44, claude-code #38905. The decision layer
338
- // (stallDecision/stallEvent, above) is pure + unit-tested; this owns the state +
339
- // side effects. 90s status default (2026-07-08 hardening: the 529-overload hang).
340
- const STALL_MS = Math.max(30000, parseInt(process.env.TP_STALL_MS, 10) || 90000)
341
- let turnActive = false // true between a sent turn and its `result`
342
- let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
343
- let stalledSent = false // one `stalled` per stall, not a storm
344
- // Auto-restart (item 1) — a transient upstream stream drop (e.g. Anthropic
345
- // "Connection closed mid-response") used to leave the session half-dead:
346
- // query loop gone, turnActive stuck, later sendTurns pushed into a dead
347
- // stream (silent freeze). The loop now self-heals: a recoverable throw
348
- // re-runs query() with resume, bounded by RESTART_MAX consecutive attempts
349
- // (reset to 0 on every successful `result`). Exhaustion/non-recoverable →
350
- // closed, so sendTurn stops silently queuing.
351
- const RESTART_MAX = 3
352
- const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
353
- let restartCount = 0
354
- // Phase 0 measurement seam. The observer receives only a coarse recovery
355
- // reason/count; it cannot alter this session's existing retry behavior.
356
- const observeRecovery = (reason, detail = {}) => { try { resilienceObserver?.({ reason, restartCount, ...detail }) } catch { /* observer is strictly read-only */ } }
357
- // Phase 1 is opt-in and applies only to a named non-Anthropic custom target. Keep
358
- // disabled/built-in sessions on the established RESTART_MAX recovery path exactly.
359
- const resilienceEnabled = !!(resilience?.policy?.enabled && resilience?.providerId && resilience.providerId !== 'anthropic')
360
- let turnResilience = null
361
- let resilienceDeadlineTimer = null
362
- let resilienceRetryPending = false
363
- let resilienceTurnRevision = 0
364
- let abortPending = false
365
- let restartTimer = null // the pending auto-restart backoff — cancelled by end()
366
- // Force-stop a true wedge (item 3): no result, no error, just silence past
367
- // FORCE_STOP_MS. As of the 2026-07-08 hardening we no longer just surface an error
368
- // and leave the hung loop in place — retryStalledTurn ABORTS the wedged SDK call (via
369
- // its AbortController, the same unblock path abort()/recreateForSwitch use) and
370
- // AUTO-RETRIES the turn ONCE, preserving the prompt. If that one retry ALSO stalls
371
- // past FORCE_STOP_MS, we fall back to the old behavior: force-stop + "send again".
372
- // stallRetried bounds it to a single auto-retry per turn (reset on each new turn /
373
- // settled result), so a sustained upstream outage can't thundering-herd retries.
374
- const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
375
- let forceStopped = false
376
- let stallRetried = false // one auto-retry per turn (the abort+re-run path below)
377
- // A manual /compact is one long, LEGITIMATELY silent model call — not a wedge. The
378
- // watchdog stands down for its whole duration (stallDecision's `compacting` branch);
379
- // only a compaction quiet for COMPACT_FORCE_STOP_MS (20 min default) is force-stopped,
380
- // and never by aborting the query. Set in sendTurn, cleared on compact_boundary (the
381
- // compaction landed) and on any result/error (the turn settled, incl. a no-op compact).
382
- const COMPACT_FORCE_STOP_MS = Math.max(FORCE_STOP_MS, parseInt(process.env.TP_COMPACT_FORCE_STOP_MS, 10) || 1200000)
383
- let compacting = false
384
- // Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
385
- // is NOT a wedge — the turn is correctly idle until the person answers. Every
386
- // interactive gate goes through requestPermission, so wrap it once to bump a
387
- // counter while a card is pending; the stall watchdog below stands down while
388
- // awaitingUser > 0. Without this, a slow human answer (e.g. a late
389
- // AskUserQuestion pick) trips FORCE_STOP_MS, the turn is force-stopped, and the
390
- // answer lands on a dead turn ("agent went silent … send again to resume").
391
- // On release, stamp lastEvtTs = now so the resumed turn isn't force-stopped on
392
- // the very next tick by a now-stale (minutes-old) timestamp.
393
- let awaitingUser = 0
394
- const _requestPermission = requestPermission
395
- requestPermission = async (req) => {
396
- awaitingUser++
397
- try { return await _requestPermission?.(req) }
398
- finally { awaitingUser = Math.max(0, awaitingUser - 1); lastEvtTs = Date.now() }
399
- }
400
- // Set while an interrupt is settling. q.interrupt() doesn't end the turn cleanly on this
401
- // SDK — it surfaces a REDUNDANT pair of terminal results (subtype `aborted` AND
402
- // `error_during_execution`) and re-inits the session. abort() emits the single canonical
403
- // aborted boundary itself; this flag tells the result handler to swallow the SDK's extra
404
- // teardown results so the web never sees a second turn-end. Without it, the web suppresses
405
- // only the first (isAbortEcho) and the second buried the flushed steer — "Stop injects the
406
- // queued message but nothing happens". Cleared by the first real (non-teardown) result.
407
- // Verified: bridge/test-steer-after-abort.mjs (Maks 2026-06-26). The teardown results
408
- // arrive async and can interleave with the NEXT turn (a late error_during_execution can
409
- // land after the steer's own output), so the window is bounded by a timer rather than
410
- // cleared on the next result — "all teardown has arrived" isn't observable. `success`
411
- // results are never swallowed, so the flushed steer's completion always surfaces.
412
- let interrupting = false
413
- let interruptingRevision = null
414
- let interruptTimer = null
415
- const INTERRUPT_SWALLOW_MS = 6000
416
- // ── Haiku suggestion fallback ──
417
- // Claude Code's own `prompt_suggestion` rarely fires in /code — the binary suppresses it
418
- // per-turn (cache_cold when a turn's tokens > 10k, rate_limit when throttled, first turn).
419
- // When it doesn't arrive shortly after `result`, we generate one ourselves with a cheap
420
- // one-shot Haiku call on the SAME subscription auth (no API key), fed ONLY the last
421
- // assistant reply (suggestions are short: "proceed" / "go with A") — fast + ~free.
422
- let sawSuggestion = false // did Claude's own prompt_suggestion fire this turn?
423
- let lastAssistantText = '' // most recent assistant prose — the only context the fallback needs
424
- let sugTimer = null // pending fallback timer
425
- const SUGGEST_FALLBACK_MS = Math.max(1200, parseInt(process.env.TP_SUGGEST_FALLBACK_MS, 10) || 2500)
426
-
427
- // emitRaw forwards an event WITHOUT stamping lastEvtTs — used for the watchdog's OWN
428
- // synthetic status/error events, which are not SDK activity and must not reset the
429
- // silence clock (else the 5-min abort would measure from our 90s status emit, not from
430
- // the last real SDK event). emit() is the normal path: it stamps liveness + clears the
431
- // one-shot stall flag on any real event other than a `stalled` re-emit.
432
- const emitRaw = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
433
- // The compaction window closes on its milestone (`compaction`) or on the turn settling
434
- // (`result`/`error` — covers a no-op "Not enough messages to compact" and a Stop). After
435
- // that the normal wedge timeline applies again.
436
- const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; if (evt && (evt.kind === 'compaction' || evt.kind === 'result' || evt.kind === 'error')) compacting = false; emitRaw(evt) }
437
- // The controller only receives its own allowlisted records. Room notes are likewise
438
- // rendered from that projection, never from an SDK error, URL, credential, or prompt.
439
- const resilienceRecord = (record) => {
440
- try { resilience?.onRecord?.(record) } catch { /* observer is never control flow */ }
441
- if (record?.outcome === 'retrying' || record?.outcome === 'cap_blocked' || record?.outcome === 'circuit_open') {
442
- emit({ kind: 'note', text: formatResilienceTrace(record) })
443
- }
444
- }
445
- const clearResilienceDeadline = () => {
446
- if (resilienceDeadlineTimer == null) return
447
- try { (resilience?.clearTimer || clearTimeout)(resilienceDeadlineTimer) } catch { /* deadline cleanup is best-effort */ }
448
- resilienceDeadlineTimer = null
449
- }
450
- const armResilienceDeadline = () => {
451
- clearResilienceDeadline()
452
- if (!turnResilience) return
453
- const timeoutMs = Number(resilience?.policy?.timeoutMs)
454
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return
455
- const schedule = resilience?.setTimer || setTimeout
456
- resilienceDeadlineTimer = schedule(() => {
457
- resilienceDeadlineTimer = null
458
- if (closed || !turnResilience || !turnActive) return
459
- // This is a bridge-authored timeout classification, never a raw provider
460
- // error. Abort the wedged iterator, then reuse the same controller budget.
461
- turnResilience.failure({ message: 'timeout' })
462
- turnActive = false
463
- try { qAc?.abort() } catch { /* the disposal wait below still fails closed */ }
464
- retryResilientTurn()
465
- }, timeoutMs)
466
- }
467
- const createTurnResilience = () => {
468
- if (!resilienceEnabled) return null
469
- try {
470
- const controller = createSameTargetResilienceController({
471
- runtime: 'claude', providers: resilience.providers || [], providerId: resilience.providerId,
472
- model: resilience.model || opts.model || model,
473
- requestedModel: resilience.requestedModel || resilience.model || opts.model || model,
474
- policy: resilience.policy, bridgeHostId: resilience.bridgeHostId,
475
- circuit: resilience.circuit, capGate: resilience.capGate,
476
- traceId: resilience.traceId,
477
- turnRev: typeof resilience.turnRev === 'function' ? resilience.turnRev() : resilience.turnRev,
478
- onRecord: resilienceRecord,
479
- })
480
- controller.start()
481
- return controller
482
- } catch { return null }
483
- }
484
- const admitResilientSubmission = async () => {
485
- if (!turnResilience) return true
486
- let admission = null
487
- try { admission = await turnResilience.preflight() } catch { return false }
488
- return admission?.admitted === true
489
- }
490
- const stallTimer = setInterval(() => {
491
- const quiet = Date.now() - lastEvtTs
492
- const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried, compacting, compactForceStopMs: COMPACT_FORCE_STOP_MS })
493
- if (action === 'none') return
494
- // ALWAYS surface — a stall is never swallowed. emitRaw so this synthetic event
495
- // doesn't reset the silence clock (see emitRaw above).
496
- const ev = stallEvent(action, quiet)
497
- if (ev) emitRaw(ev)
498
- if (action === 'status') { stalledSent = true; return }
499
- if (action === 'retry') {
500
- forceStopped = true
501
- if (turnResilience) {
502
- // The legacy watchdog must never create a second retry ledger. Route
503
- // its terminal timeout through the same controller; visible output or
504
- // an exhausted attempt budget makes the following preflight fail closed.
505
- observeRecovery('resilience_stall', { quietMs: quiet })
506
- turnResilience.failure({ message: 'timeout' })
507
- turnActive = false
508
- try { qAc?.abort() } catch { /* disposal is awaited by the shared retry path */ }
509
- retryResilientTurn()
510
- return
511
- }
512
- observeRecovery('stall_replay', { quietMs: quiet })
513
- retryStalledTurn(quiet)
514
- return
515
- }
516
- // 'giveup' — the one auto-retry ALSO stalled past FORCE_STOP_MS. Fall back to the
517
- // pre-2026-07-08 behavior: force-stop the turn so between-turns updates unblock, and
518
- // let the human resend. The wedged loop is left in place; if it later throws, the
519
- // catch self-heals.
520
- forceStopped = true
521
- turnActive = false
522
- }, 5000)
523
- stallTimer.unref?.()
524
-
525
- // Apply a permission-mode change to the live SDK *outside* any hook callback.
526
- // setPermissionMode is a streaming control request; awaiting it from INSIDE the
527
- // PreToolUse hook (which the SDK is itself awaiting) jams the control channel —
528
- // the call throws, and when that was silently swallowed the SDK stayed in plan
529
- // mode, so the agent got bounced back to planning after every approved edit
530
- // (the "PLAN READY every turn" loop). Deferring to the next tick lets the hook
531
- // return first, freeing the channel. Errors are logged to the bridge, not eaten.
532
- const scheduleSdkMode = (next) => {
533
- setTimeout(() => {
534
- Promise.resolve(q?.setPermissionMode?.(next)).catch((e) =>
535
- console.error(`[claude-session] setPermissionMode(${next}) failed:`, e?.message || e))
536
- }, 0)
537
- }
538
-
539
- // PreToolUse — fires on EVERY tool call (the universal gate). The mode policy
540
- // mirrors Claude Code exactly: reads never prompt (any mode); Auto-accept
541
- // edits auto-approves non-destructive writes; Bash / network / destructive
542
- // always round-trip to the room's risk-tiered permission card.
543
- const preTool = async (hookInput) => {
544
- const toolName = hookInput.tool_name
545
- const toolInput = hookInput.tool_input
546
- // The hook input carries the turn's active reasoning effort (post any
547
- // model downgrade) — the real signal for "thinking with <X> effort".
548
- // Surface it to the room when it changes; absent on models without effort.
549
- const lvl = hookInput.effort?.level
550
- if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
551
- // Flow CONDUCTOR hard-stop: a conductor must DECOMPOSE using ONLY Read/Grep/Glob (which
552
- // are auto-allowed, no card) then ExitPlanMode. FOUR tool classes otherwise stall it on a
553
- // permission card on the now-hidden conductor terminal: (1) Task/Agent subagents — the
554
- // model's parallelize instinct fans out Explore agents despite the prompt; (2) Bash —
555
- // the model reaches for `find`/`grep`/`wc`, and Bash is medium-risk → a card in plan
556
- // mode; (3) Write/Edit — the model's plan-mode habit is to WRITE A PLAN DOC
557
- // (`~/.claude/plans/*.md`) instead of emitting the task-graph; that write pops a card AND
558
- // never produces tasks, so the flow wedges in `planning` forever (FL-B1). Deny ALL and
559
- // redirect to ExitPlanMode. (blockSubagents is set only for the conductor session —
560
- // lanes build under their own bypass/one-hop rules.)
561
- // FL-B1 — the conductor SUBMITS its decomposition by Writing the task-graph JSON to the
562
- // sentinel file FLOW_PLAN.json. Write is a DIRECT (non-deferred) tool, so this avoids the
563
- // deferred-tool/ToolSearch path that hangs every other submit route (ExitPlanMode AND the
564
- // submit_flow_plan MCP tool are deferred → ToolSearch → hang). We intercept that Write
565
- // here (the file is never actually written): validate via onSubmitPlan + broadcast the
566
- // plan, then feed the result straight back as the tool result so the conductor re-emits on
567
- // a malformed plan or stops on success.
568
- const isConductorWrite = toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'NotebookEdit'
569
- const writePath = toolInput?.file_path || toolInput?.notebook_path || ''
570
- if (blockSubagents && isConductorWrite && /(?:^|[/\\])FLOW_PLAN\.json$/i.test(writePath)) {
571
- const planText = toolInput?.content ?? toolInput?.new_string ?? ''
572
- let res = { ok: false, message: 'Plan submission is not wired for this session.' }
573
- try { res = (onSubmitPlan && (await onSubmitPlan(planText))) || res } catch (e) { res = { ok: false, message: `Plan submission failed: ${e?.message || e}` } }
574
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
575
- }
576
- if (blockSubagents && (toolName === 'Task' || toolName === 'Agent' || toolName === 'Bash' || isConductorWrite)) {
577
- const reason = (toolName === 'Bash')
578
- ? 'Flow conductors do NOT run Bash — it pops a permission card and stalls the flow. Use the Grep tool (not `grep`/`rg`), the Glob tool (not `find`/`ls`), and Read (not `cat`/`head`) — those run with no prompt. Then DECOMPOSE and submit by Writing your task-graph JSON to the file FLOW_PLAN.json.'
579
- : isConductorWrite
580
- ? 'Flow conductors do NOT write project or plan-document files. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). To SUBMIT your decomposition, use the Write tool with file_path "FLOW_PLAN.json" and content = your task-graph JSON ({"summary":"…","tasks":[…]}). That single Write IS how you submit — the room ingests it directly. Do not write any other file.'
581
- : 'Flow conductors do NOT spawn subagents — it stalls the flow on a permission card. Inspect the codebase yourself with Read/Grep/Glob (no prompt). Then DECOMPOSE and submit by Writing your task-graph JSON to the file FLOW_PLAN.json. Do not call Task/Agent again.'
582
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
583
- }
584
- // FL-B4 (review lane) — an adversarial reviewer records its verdict by Writing
585
- // FLOW_REVIEW.json; the hook routes it to the verdict logic (revert-on-fail + mark the
586
- // review slice done). Same deferred-tool workaround as the builder's FLOW_DONE.
587
- if (onReviewVerdict && (toolName === 'Write' || toolName === 'Edit') && /(?:^|[/\\])FLOW_REVIEW(\.[a-zA-Z0-9]+)?$/.test(toolInput?.file_path || '')) {
588
- let res = { ok: false, message: 'verdict signal failed' }
589
- try { res = (await onReviewVerdict(toolInput?.content ?? toolInput?.new_string ?? '')) || res } catch (e) { res = { ok: false, message: `verdict signal failed: ${e?.message || e}` } }
590
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
591
- }
592
- // FL-B1 (lane) — a flow lane signals its slice is done by Writing a file named FLOW_DONE
593
- // (the mark_flow_done MCP tool is deferred → ToolSearch → hangs; Write is direct). Route
594
- // that Write to the done logic and feed the result back; the file is never written.
595
- if (onLaneDone && (toolName === 'Write' || toolName === 'Edit') && /(?:^|[/\\])FLOW_DONE(\.[a-zA-Z0-9]+)?$/.test(toolInput?.file_path || '')) {
596
- let res = { ok: false, message: 'done signal failed' }
597
- try { res = (await onLaneDone(toolInput?.content ?? toolInput?.new_string ?? '')) || res } catch (e) { res = { ok: false, message: `done signal failed: ${e?.message || e}` } }
598
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: res.message } }
599
- }
600
- // S5 (slice 1b) — REVIEW-LANE WRITE-BLOCK. A review lane is ADVERSARIAL: it reads +
601
- // runs/tests the slice(s) it reviews and emits a verdict, but it must be STRUCTURALLY
602
- // unable to Edit/Write/commit the code under review (a reviewer that edits is a second
603
- // builder → a false PASS). `reviewGate` is set ONLY on review lanes (bridge.mjs wires it
604
- // to reviewGateDecision over the reviewed slices' worktree roots + the lane's own
605
- // FLOW_REVIEW.json). It runs AFTER the FLOW_REVIEW.json verdict intercept above, so the
606
- // reviewer can still emit its verdict, and BEFORE the normal auto-allow path, so the deny
607
- // is structural even in the lane's bypassPermissions mode. Read + run/test stay allowed
608
- // (the gate allows read-only tools + non-write Bash — the reviewer's whole method). The
609
- // real mitigation for Bash-quoting bypasses is worktree isolation (the review lane runs
610
- // in its OWN worktree, not the slice's) — guaranteed by the spawn path.
611
- const reviewDeny = reviewGatePreToolDecision({ reviewGate, toolName, toolInput })
612
- if (reviewDeny) return reviewDeny
613
- // ThinkPool cross-terminal READ (B2) is read-only + within-room — never
614
- // prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
615
- if (toolName === 'mcp__thinkpool__read_terminal') {
616
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
617
- }
618
- // Bridge-owned visual QA is constrained to the lane cwd + a loopback URL
619
- // created by the bridge itself, so it does not need a room permission card.
620
- if (/^mcp__thinkpool__preview_(start|capture|inspect|stop)$/.test(toolName)) {
621
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (contained bridge-owned viewport preview).' } }
622
- }
623
- // FL-B1 — the Flow conductor submits its task-graph through this tool (replaces the
624
- // deferred/hanging ExitPlanMode). It only broadcasts a plan for HUMAN approval — no FS or
625
- // system effect — so auto-allow it (the conductor runs in plan mode, which would otherwise
626
- // card it and re-wedge the flow).
627
- if (toolName === 'mcp__thinkpool__submit_flow_plan') {
628
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (Flow plan submission — broadcasts the task-graph for human approval).' } }
629
- }
630
- // ThinkPool cross-ROOM READ (list_sessions / read_session) is read-only. It reaches
631
- // the account's own other rooms on this machine (Tier 1) and the partner's rooms on
632
- // the pair bus (Tier 2) — both firewalled server-side (the supervisor only serves
633
- // owned rooms; the pair channel is RLS-gated to the two real partners), so it is the
634
- // same trust class as read_terminal → auto-allow. POSTING across rooms
635
- // (post_to_session) is NOT read-only — it gates on a card at both ends below.
636
- if (toolName === 'mcp__thinkpool__list_sessions' || toolName === 'mcp__thinkpool__read_session') {
637
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-session read — your own rooms).' } }
638
- }
639
- // ThinkPool cross-terminal CLOSE (Tier C+) — closes only a lane the agent itself
640
- // spawned (bridge-side spawnedBy check), never a sibling's work nor the host
641
- // terminal. Low-risk lane management → auto-allow (surfaced as a card for visibility).
642
- if (toolName === 'mcp__thinkpool__close_terminal') {
643
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
644
- }
645
- // A spawned worker is the autonomous execution unit: always auto-allow the tool.
646
- // Bridge-side resolveAgentOpen pins every worker to bypassPermissions regardless
647
- // of parent mode or the legacy mode argument. The one-worker-tier / plan width /
648
- // machine / burst / TP_SPAWN_OFF caps still fail closed before the lane opens.
649
- if (toolName === 'mcp__thinkpool__spawn_terminal') {
650
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (autonomous ThinkPool worker; bounded by room caps and isolated in its own worktree).' } }
651
- }
652
- // Independent main terminals keep their requested/inherited permission mode.
653
- // Raising a new main terminal to bypass from a non-bypass parent still asks once.
654
- if (toolName === 'mcp__thinkpool__open_main_terminal') {
655
- const childMode = toolInput?.mode || mode // inherit this lane's mode by default
656
- const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
657
- if (!escalating) {
658
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (open a fresh ThinkPool terminal in ${childMode} mode — inherited, no escalation; bounded by room caps).` } }
659
- }
660
- let decision = 'deny'
661
- try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: { ...toolInput, mode: childMode }, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
662
- const allowed = decision === 'allow'
663
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass terminal creation approved in the ThinkPool room — the new terminal runs autonomously.' : 'Bypass terminal creation denied in the room — do not retry as bypass; open it without mode:bypassPermissions or ask what to do.' } }
664
- }
665
- // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
666
- // First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
667
- // blocked or runaway post never even raises a card. THEN the consent SCOPE
668
- // (crossPostNeedsCard, 2026-07-03): a post auto-allows with NO card in exactly
669
- // two cases — (a) this lane is in bypassPermissions (bypass means bypass), or
670
- // (b) this lane SPAWNED the target (parent→child orchestration; bridge-side
671
- // didSpawnTarget resolves the sibling + checks spawnedBy). Every OTHER post — a
672
- // non-owner into a lane it did not spawn, in any non-bypass mode — still round-
673
- // trips the 'high'-risk card (Deny / Allow-once only). This unblocks automated
674
- // conductor→lane orchestration (the 3-stacked-Dispatch-cards bug) WITHOUT loosening
675
- // the invariant: a lane can never be posted into by a non-owner without a human tap,
676
- // except in bypass. Cross-ROOM post_to_session (dual-consent) is untouched below.
677
- // Spec: docs/specs/2026-07-03-xpost-consent-scope.md.
678
- if (toolName === 'mcp__thinkpool__post_to_terminal') {
679
- const gate = crossPostGate ? crossPostGate() : { ok: true }
680
- if (!gate.ok) {
681
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-terminal post not allowed right now.' } }
682
- }
683
- const spawnedByMe = didSpawnTarget ? !!didSpawnTarget(toolInput?.terminal) : false
684
- if (!crossPostNeedsCard({ mode, spawnedByMe })) {
685
- const why = mode === 'bypassPermissions'
686
- ? 'Auto-approved (cross-terminal post in bypassPermissions mode — bypass means bypass).'
687
- : 'Auto-approved (cross-terminal post into a lane you spawned — parent→child orchestration, no per-post tap).'
688
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: why } }
689
- }
690
- let decision = 'deny'
691
- try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
692
- const allowed = decision === 'allow'
693
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-terminal post approved in the ThinkPool room.' : 'Cross-terminal post denied in the room — do not retry; ask what to do instead.' } }
694
- }
695
- // ThinkPool cross-ROOM POST (Tier 3) WRITES into ANOTHER room's agent — possibly
696
- // on another person's machine. This is the SENDER-side gate (the recipient room
697
- // raises its OWN receipt card before anything injects — dual consent). Same shape
698
- // as post_to_terminal: a bridge-side precheck (kill-switch / roomHop / per-turn
699
- // cap) so a blocked post never raises a card, then a 'high'-risk human card here.
700
- if (toolName === 'mcp__thinkpool__post_to_session') {
701
- const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
702
- if (!gate.ok) {
703
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-room post not allowed right now.' } }
704
- }
705
- let decision = 'deny'
706
- try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
707
- const allowed = decision === 'allow'
708
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-room post approved in the sending room — the target room will be asked to accept it.' : 'Cross-room post denied in the room — do not retry; ask what to do instead.' } }
709
- }
710
- // ── Plan approval — ExitPlanMode is how the agent presents its plan in
711
- // plan mode. Render a dedicated plan card (not the generic perm card) with
712
- // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
713
- // (deny → stay planning). On approval we flip the SDK permission mode so
714
- // subsequent tools actually execute.
715
- if (toolName === 'ExitPlanMode') {
716
- // GUARANTEE: a plan card only ever appears when the room is ACTUALLY in Plan
717
- // mode (the user pressed ⇧⇥ → Plan). If the agent reaches ExitPlanMode while
718
- // the room is in any other mode (default / acceptEdits / bypassPermissions),
719
- // it entered plan mode on its own — the user never asked. Don't surface an
720
- // unsolicited "PLAN READY" card that blocks them; re-assert the room's real
721
- // mode and let the work proceed. This is the backstop for plan-mode leaking
722
- // in regardless of source (sticky localStorage, SDK default, a host-global
723
- // brainstorm nudge): in a ThinkPool room, plan is opt-in, never imposed.
724
- if (mode !== 'plan') {
725
- scheduleSdkMode(mode) // snap the SDK back out of plan, into the real mode
726
- emit({ kind: 'mode', mode }) // keep the room chip honest
727
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `The ThinkPool room is in ${mode} mode, not Plan — the user did not ask for a plan. Do NOT call ExitPlanMode; proceed and make the changes directly. Only present a plan if the user switches the room to Plan mode or explicitly asks.` } }
728
- }
729
- let choice = 'keep'
730
- try { choice = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'plan', plan: toolInput?.plan || '' }) ?? 'keep' }
731
- catch { choice = 'keep' }
732
- if (choice === 'run' || choice === 'accept') {
733
- const next = choice === 'accept' ? 'acceptEdits' : 'default'
734
- mode = next // our auto-allow gate updates immediately
735
- scheduleSdkMode(next) // flip the SDK AFTER this hook returns (see above)
736
- emit({ kind: 'mode', mode: next })
737
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Plan approved in the ThinkPool room — proceed (${next} mode).` } }
738
- }
739
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.' } }
740
- }
741
- // ── AskUserQuestion — the agent asks the user a multiple-choice question.
742
- // It can't run its interactive dialog headless (allowing it errors), so we
743
- // render the choice card in the room and feed the selection back as the tool
744
- // outcome. PreToolUse can't inject a tool_result, but a deny's reason IS what
745
- // the model receives — so we deny and put the answer in the reason.
746
- if (toolName === 'AskUserQuestion') {
747
- let decision = ''
748
- try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'ask', questions: toolInput?.questions || [] }) ?? '' }
749
- catch { decision = '' }
750
- return askUserQuestionHookOutput(decision)
751
- }
752
- const risk = classifyRisk(toolName, toolInput)
753
- // "Don't ask again" is keyed by tool + risk tier, so allowing medium Bash
754
- // never silently allows a future destructive one (high always re-asks).
755
- const sig = `${toolName}:${risk}`
756
- const safeDoc = isSafeDocWrite(toolName, toolInput)
757
- const auto = autoAllow({ toolName, input: toolInput, mode, alwaysAllow })
758
- let decision = 'allow'
759
- if (!auto) {
760
- try {
761
- decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'deny'
762
- } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
763
- // "Allow & don't ask again" — remember the signature, then allow.
764
- if (decision === 'always') { alwaysAllow.add(sig); decision = 'allow' }
765
- }
766
- // On deny, permissionDecisionReason IS what the model receives as the
767
- // tool error — make it a real instruction, not an opaque tag.
768
- const denied = decision === 'deny'
769
- if (denied && turnResilience) turnResilience.failure({ permissionDenied: true })
770
- return {
771
- continue: true,
772
- hookSpecificOutput: {
773
- hookEventName: 'PreToolUse',
774
- permissionDecision: denied ? 'deny' : 'allow',
775
- permissionDecisionReason: denied
776
- ? 'Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.'
777
- : auto
778
- ? (safeDoc ? 'Auto-approved (mandated doc write — .claude/SESSIONS/ or CLAUDE.md).' : `Auto-approved (${mode}).`)
779
- : 'Approved in the ThinkPool room.',
780
- },
781
- }
782
- }
783
-
784
- const opts = {
785
- abortController: ac,
786
- permissionMode: mode,
787
- hooks: { PreToolUse: [{ hooks: [preTool] }] },
788
- // Enable AskUserQuestion (the agent's multiple-choice card) in EVERY mode.
789
- // The CLI only adds AskUserQuestion / EnterPlanMode / ExitPlanMode to a
790
- // session's toolset when the host declares it can service an interactive
791
- // permission — i.e. when a `canUseTool` callback is present. Without it the
792
- // tool is absent and the model gets "AskUserQuestion exists but is not
793
- // enabled in this context", so the room's card handler above was dead code.
794
- // Its PRESENCE is the whole point: in bypassPermissions this callback is
795
- // shadowed (never invoked — bypass auto-approves before it, per the SDK's
796
- // CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning), and in gated modes the
797
- // PreToolUse hook runs first and stays authoritative (a hook deny — which is
798
- // exactly how the AskUserQuestion card feeds its answer back — short-circuits
799
- // canUseTool). So the human-gate holds in all modes: an ask-card always waits
800
- // for a person via requestPermission inside the hook. This allow is a no-op
801
- // relative to the hook, never a second gate that could override a denial.
802
- canUseTool: async (_toolName, input) => ({ behavior: 'allow', updatedInput: input }),
803
- // Load the host's REAL Claude environment — user + project + local settings —
804
- // so custom slash commands (.claude/commands/*.md), CLAUDE.md and agents work
805
- // in the room exactly as in the user's own CLI. The Agent SDK isolates by
806
- // default (no filesystem settings), which is why a custom /command came back
807
- // "isn't available in this environment"; we opt in explicitly so it holds
808
- // across SDK versions. The room's PreToolUse gate still runs and stays
809
- // authoritative (hooks fire regardless of any loaded permission rules).
810
- settingSources: ['user', 'project', 'local'],
811
- // Counteract host-global "plan/brainstorm before any work" nudges that ride in
812
- // through settingSources:'user' — notably the superpowers plugin's SessionStart
813
- // hook ("You MUST use brainstorming before any creative work… about to enter
814
- // plan mode?"). In a Code room those make the agent answer a plain "add/fix X"
815
- // by presenting a plan and calling ExitPlanMode, so the room shows a
816
- // "PLAN READY — APPROVE TO START" card the user never asked for ("always flips
817
- // to plan mode"). This is instruction-driven, independent of permissionMode —
818
- // a bypass session still planned. Bias the room session to DO the work; only
819
- // plan when the room is actually in Plan mode (⇧⇥) or the user explicitly asks.
820
- appendSystemPrompt: [
821
- ...(terminalRolePrompt ? [terminalRolePrompt] : []),
822
- ...(rolePrompt ? [rolePrompt] : []),
823
- 'ENVIRONMENT (authoritative — overrides any user-global CLAUDE.md or memory that claims otherwise): You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser, via the thinkpool-pair bridge.',
824
- THINKPOOL_RUNTIME_AUTHORITY_RULE,
825
- 'Bias strongly toward DOING the work, not stalling in plan-mode ceremony. For a small "add / fix / change X" request, just make the change directly (for a bigger build, right-size it — see DEFAULT BUILD WORKFLOW below).',
826
- 'Do NOT enter plan mode, do NOT call ExitPlanMode, and do NOT auto-invoke a brainstorming/planning skill UNLESS the user has switched the room into Plan mode or explicitly asks you to plan, design, or brainstorm first.',
827
- 'Any host-global instruction that says you must always brainstorm or plan before creative work does NOT apply here — this room is the exception.',
828
- // DEFAULT BUILD WORKFLOW (2026-07-02 decision — docs/specs/2026-07-02-flow-default-build-workflow.md):
829
- // no summon word. The agent INFERS build intent and runs plan→(optional)fan-out→build→self-verify,
830
- // right-sized to the task, surfaced in the room's EXISTING surfaces (chat + lane list) — never a new panel,
831
- // never plan-mode's approval card. Trivial asks stay single-lane with zero ceremony.
832
- THINKPOOL_CASCADE_RULE,
833
- 'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',
834
- ...THINKPOOL_REMOTE_DELIVERY_RULES,
835
- 'CROSS-TERMINAL AWARENESS: ROOM NOW is the default roster and already satisfies the room check when it has enough detail. Do not repeat it with a no-argument read_terminal call unless it is missing or truncated. Use a targeted read_terminal call only when the current task depends on a specific lane’s detailed activity; never poll. Identify a terminal by its NAME or stable ref/id, never by an on-screen number like "Terminal 2" — positional labels renumber when a terminal is closed. The tool is read-only and its optional roster lookup is budgeted separately from bounded targeted transcript reads.',
836
- 'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
837
- 'TERMINAL CREATION CONTRACT: main terminals and workers use different tools. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal(name?, task?, model?) — even for ordinary work or a handoff and even without the words Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for agent-decided bounded WORKER SUB-TERMINALS or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main terminals are independent, keep their requested permission mode, and are not owned/closed through Ensemble.',
838
- 'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
839
- 'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
840
- 'SUBAGENT POLICY: in this room, a main terminal delegates worker slices through visible spawn_terminal Ensemble lanes. A person-requested new or separate terminal is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
841
- 'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
842
- 'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
843
- 'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
844
- ].join(' '),
845
- // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
846
- // flow during a turn. We ignore the fine-grained stream_event partials in
847
- // the loop; only the coarse thinking_tokens system message is surfaced.
848
- includePartialMessages: true,
849
- // Claude Code's own "predicted next prompt" — the SDK emits at most one
850
- // `prompt_suggestion` per turn, AFTER the `result` message, suppressed on the
851
- // first turn / after API errors / in plan mode, and it piggybacks the parent's
852
- // prompt cache (nearly free). We surface it verbatim to the room composer as a
853
- // ghost-text chip; nothing is generated on our side. The loop keeps iterating
854
- // past `result`, so the post-result suggestion is received. Global off switch:
855
- // CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false.
856
- promptSuggestions: true,
857
- }
858
- // Measurement lever (default OFF — no behavior change). TP_MCP_STRICT=1 makes the
859
- // room session skip booting the host's filesystem-configured MCP servers
860
- // (strictMcpConfig + empty mcpServers map), isolating the MCP share of cold-start
861
- // in the spawn→ready log below. settingSources still loads slash commands /
862
- // CLAUDE.md / agents either way — strict only governs MCP server sourcing. Flip
863
- // this to the default (or curate a slim allowlist) once the numbers justify it.
864
- if (process.env.TP_MCP_STRICT === '1') { opts.strictMcpConfig = true; opts.mcpServers = {} }
865
- // In-process ThinkPool tools (B2 read_terminal) — MERGED with the host's
866
- // filesystem MCP (non-strict), or the sole servers under TP_MCP_STRICT. The SDK
867
- // namespaces these as mcp__<key>__<tool>; the PreToolUse gate auto-allows them.
868
- if (mcpServers && Object.keys(mcpServers).length) opts.mcpServers = { ...(opts.mcpServers || {}), ...mcpServers }
869
- if (cwd) opts.cwd = cwd
870
- if (model) opts.model = model
871
- opts.effort = effort
872
- // NB: resume is NOT baked into opts — runQuery sets it per-run from the live `sessionId`
873
- // (seeded from `resume`). That makes the resume target controllable: a re-create can point
874
- // it at persistedSessionId, and the "No conversation found" recovery can DROP it (clearing
875
- // sessionId) to re-run fresh. If it were in opts, the {...opts} spread would keep resuming
876
- // the dead id even after we cleared sessionId (Max 2026-07-02 recovery loop).
877
- // Force-enable Claude Code's predicted-next-prompt. `promptSuggestions: true`
878
- // alone defers to the server-side `tengu_prompt_suggestion` feature gate, which
879
- // Anthropic flips on/off during rollout — so the ghost-text chip silently died
880
- // mid-day with NO change on our side. CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION is the
881
- // documented override. opts.env REPLACES the child env, so spread the inherited one.
882
- opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '1' }
883
-
884
- // Transcript healing lives at the TRUE resume point — see runQuery, right before
885
- // query() is (re)built. The old factory-time heal was gated on the closure constant
886
- // `resume`, so it ran only on the first open; a `/model` switch or a restart re-points
887
- // the live `sessionId` and re-enters runQuery, resuming a transcript that was never
888
- // re-healed (finding FBL-1). Healing inside runQuery covers EVERY resume path.
889
-
890
- // One-shot Haiku fallback suggestion. Runs on the SAME subscription auth as the room
891
- // (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
892
- // settingSources, no MCP, no tools — so it's a fast cold call, not a full session boot.
893
- // Fed only `lastAssistantText` (trimmed). Never blocks a turn; any failure is silent.
894
- //
895
- // BUILT-IN ANTHROPIC PATH ONLY (`suggest`). On a lane running a registered custom
896
- // provider, opts.env carries that provider's ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN —
897
- // so this hardcoded `claude-haiku-4-5` call would spend the USER'S OWN BYOK credit on a
898
- // composer prefill they never asked for, at whatever (wrong, flagship) model the endpoint
899
- // maps the unknown id to. z.ai silently serves glm for any `claude-*` id, which is exactly
900
- // why this went unnoticed; a stricter provider 400s. This is an Anthropic-subscription
901
- // freebie — it does not get a per-provider small model, it gets switched off.
902
- const haikuSuggest = async () => {
903
- if (!suggest) return
904
- const seed = (lastAssistantText || '').trim().slice(-1500)
905
- if (!seed || closed) return
906
- try {
907
- let out = await claudeOneShot({
908
- prompt: `You are predicting the user's NEXT chat message in a live coding session, to prefill their composer. Given the assistant's latest reply below, output the single most likely next user message — short and natural (often just "proceed", "go with option A", "yes do that", or a brief follow-up). One line, <=12 words, imperative, no preamble, no quotes, no markdown.\n\nAssistant's latest reply:\n"""\n${seed}\n"""\n\nNext user message:`,
909
- model: 'claude-haiku-4-5', cwd, env: opts.env, timeoutMs: 8000,
910
- })
911
- out = out.trim().split('\n')[0].replace(/^["'`]+|["'`]+$/g, '').trim().slice(0, 140)
912
- if (out && !sawSuggestion && !closed && !/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(out)) {
913
- emit({ kind: 'suggestion', text: out, source: 'haiku' })
914
- process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(out)}\n`)
915
- }
916
- } catch { /* fallback failed (rate limit / abort / model error) — silent */ }
917
- }
918
-
919
- const admitColdStart = () => {
920
- const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
921
- if (gate?.ok !== false) return true
922
- emit({ kind: 'error', message: gate.reason || 'Host memory is critically low. This agent runtime was not started.', recoverable: true })
923
- return false
924
- }
925
-
926
- const runQuery = async ({ admitted = false } = {}) => {
927
- if (!started && !admitted && !admitColdStart()) return false
928
- if (spawnT0 == null) spawnT0 = Date.now()
929
- started = true
930
- // Per-query AbortController (child of the session `ac`): lets a model switch kill
931
- // just this query. Resolve the previous qDone in case it's still pending.
932
- if (qDoneResolve) { try { qDoneResolve() } catch { /* noop */ } }
933
- qDone = new Promise((res) => { qDoneResolve = res })
934
- const myAc = new AbortController()
935
- qAc = myAc
936
- const onParentAbort = () => { try { myAc.abort() } catch { /* noop */ } }
937
- ac.signal.addEventListener('abort', onParentAbort, { once: true })
938
- try {
939
- // Heal the transcript we're about to REPLAY — at the true resume point, keyed on the
940
- // LIVE `sessionId` (not the factory-time `resume` constant). `sessionId` mutates:
941
- // recreateForSwitch re-points it to persistedSessionId and the restart / bad-resume
942
- // forks re-point it too, each re-entering runQuery. A `/model` switch (or restart) that
943
- // resumed a corrupted transcript — a dangling tool_use / tool_result-in-assistant left
944
- // by an aborted mid-tool turn — used to hit an Anthropic 400 (H42 class) and wedge the
945
- // lane on every send (finding FBL-1). sanitizeSession is idempotent + a no-op on clean
946
- // transcripts, so healing before EVERY resume is free. See transcript-sanitize.mjs.
947
- if (sessionId) {
948
- const healed = sanitizeSession(cwd || process.cwd(), sessionId)
949
- if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} malformed tool block(s) in the transcript before resume.\n`)
950
- }
951
- q = query({ prompt: input.stream, options: { ...opts, abortController: myAc, ...(sessionId ? { resume: sessionId } : {}) } })
952
- for await (const m of q) {
953
- if (closed) break
954
- switch (m.type) {
955
- case 'system':
956
- // Live thinking-token progress — the running estimate while the
957
- // model reasons, surfaced as the indicator's ↓ N tokens. Coarse,
958
- // emitted during extended thinking; not a per-token stream.
959
- if (m.subtype === 'thinking_tokens') {
960
- // The reasoning-phase estimate (smooth, but approximate). Only
961
- // surface it BEFORE real output streams — once message_delta gives
962
- // us authoritative output_tokens (which already include thinking),
963
- // that supersedes the estimate so the count never jumps backwards.
964
- if (turnBaseOut + curMsgOut === 0) emit({ kind: 'thinking_tokens', tokens: m.estimated_tokens, delta: m.estimated_tokens_delta })
965
- break
966
- }
967
- // compact_boundary — the authoritative compaction event (replaces the
968
- // old heuristic): trigger ('manual' for /compact vs 'auto') + the token
969
- // count before compaction. Emit a real recap card the room can pin.
970
- if (m.subtype === 'compact_boundary') {
971
- forceFullReminder = true
972
- emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
973
- // Refresh the ctx% meter RIGHT AFTER compaction — it otherwise only updates at
974
- // turn-end, so the mode row kept showing the stale PRE-compaction window
975
- // ("ctx 90% even though we just compacted", Max 2026-07-02). Re-query the live
976
- // context usage so the meter drops to the compacted size immediately.
977
- ;(async () => {
978
- try {
979
- const c = await q?.getContextUsage?.()
980
- if (c) { emit({ kind: 'usage', ctx: correctContext({ used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }) }); if (c.model) curModel = c.model }
981
- } catch { /* control req may be unavailable */ }
982
- })()
983
- break
984
- }
985
- if (m.session_id) sessionId = m.session_id
986
- // Cold-start timing — the init system message is when the terminal is
987
- // usable, so spawn→here IS the latency the user waits on. One line per
988
- // open. Compare a normal open vs one with TP_MCP_STRICT=1 to read off how
989
- // many ms the host MCP servers cost. (each resume also pays a sanitizeSession heal — see runQuery.)
990
- if (!readyLogged) {
991
- readyLogged = true
992
- process.stderr.write(`\n ◆ session ready in ${Date.now() - spawnT0}ms — MCP ${process.env.TP_MCP_STRICT === '1' ? 'OFF' : 'on'}${resume ? ', resume' : ', fresh'}.\n`)
993
- }
994
- // The init advertises both real Skills and Claude Code's TUI/profile
995
- // palette. Normalise it to the structured room contract: supported
996
- // bridge controls plus installed Skills, never unsafe interactive or
997
- // profile-global commands (see claude-command-catalog.mjs).
998
- // Prefer the INTENDED model (opts.model — set at open and on every /model switch)
999
- // over the message's own `m.model`. A RESUMED session REPLAYS the transcript's old
1000
- // init messages, all carrying the PRE-switch model, and each one used to reset the
1001
- // displayed model back (Max 2026-07-02: switch to Fable, then 24 replayed
1002
- // "init model=claude-opus-4-8" dragged the chip back to Opus with no result yet to
1003
- // correct it). opts.model reflects what the session will actually run, so trust it.
1004
- curModel = opts.model || m.model || model || curModel
1005
- emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands: normalizeClaudeCommandCatalog({ slashCommands: m.slash_commands, skills: m.skills }) })
1006
- // AskUserQuestion regression guard — the init message carries the session's
1007
- // real tool list. If a caret SDK bump silently dropped AskUserQuestion from
1008
- // it (the 2026-07-08 outage), surface it loudly instead of a dead card. One-
1009
- // shot per session (resumed sessions replay init messages). Fails open when
1010
- // the message shape carries no tools[].
1011
- if (!sdkToolsChecked && Array.isArray(m.tools)) {
1012
- sdkToolsChecked = true
1013
- const regression = askUserQuestionRegression(m.tools, SDK_VERSION)
1014
- if (regression) {
1015
- process.stderr.write(`\n ⚠⚠ ${regression}\n`)
1016
- emit({ kind: 'error', message: regression })
1017
- }
1018
- }
1019
- // Dynamic model list — ask the SDK for its OWN supported models and surface
1020
- // them to the room so the /model picker renders live options (incl. new
1021
- // models like Fable) instead of a hardcoded three-item list. Fire-and-forget:
1022
- // older rooms just never receive it and keep their static fallback; a query
1023
- // that lacks supportedModels() (old SDK) silently no-ops. One-shot per session.
1024
- if (!modelsSent) {
1025
- modelsSent = true
1026
- Promise.resolve(q?.supportedModels?.()).then((ms) => {
1027
- const models = (ms || [])
1028
- .map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
1029
- .filter((x) => x.value)
1030
- if (models.length) emit({ kind: 'models', models })
1031
- }).catch(() => { /* no list available — room keeps its fallback */ })
1032
- }
1033
- break
1034
- case 'assistant':
1035
- clearResilienceDeadline()
1036
- // Any assistant block (including a tool_use) has crossed the replay
1037
- // boundary. A same-target retry is only safe before visible output.
1038
- turnResilience?.visibleOutput()
1039
- // Stamp tool-call start times so tool_result can report a duration.
1040
- for (const b of (m.message?.content || [])) {
1041
- if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, { at: Date.now(), name: b.name, input: b.input })
1042
- }
1043
- // parentToolUseId: non-null when this assistant message comes from a
1044
- // sub-agent (Task tool) — the universal nesting spine. Thread it so the
1045
- // room can group sub-agent activity under its parent Task card.
1046
- // Capture the latest assistant prose — the only context the Haiku fallback needs.
1047
- { const txt = (m.message?.content || []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim(); if (txt) lastAssistantText = txt }
1048
- emit({ kind: 'assistant', blocks: simplifyBlocks(m.message?.content), parentToolUseId: m.parent_tool_use_id || null })
1049
- break
1050
- case 'user':
1051
- // tool_result blocks arrive on the user-role echo
1052
- for (const b of (m.message?.content || [])) {
1053
- if (b?.type === 'tool_result') {
1054
- clearResilienceDeadline()
1055
- turnResilience?.visibleOutput({ tool: true })
1056
- const start = toolStart.get(b.tool_use_id)
1057
- if (start != null) toolStart.delete(b.tool_use_id)
1058
- const evidence = evidenceForToolResult(start?.name, b.content)
1059
- emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, ...(evidence ? { evidence } : {}), isError: !!b.is_error, durationMs: start?.at != null ? Date.now() - start.at : undefined, parentToolUseId: m.parent_tool_use_id || null })
1060
- }
1061
- }
1062
- break
1063
- case 'stream_event': {
1064
- // Live output-token progress for the thinking indicator. message_delta
1065
- // carries the running output_tokens for the current assistant message;
1066
- // message_start opens a new one (fold the finished message into the
1067
- // turn base first). Authoritative + monotonic within a turn.
1068
- const ev = m.event
1069
- if (ev?.type === 'message_start') {
1070
- turnBaseOut += curMsgOut
1071
- curMsgOut = ev.message?.usage?.output_tokens || 0
1072
- emit({ kind: 'thinking_tokens', tokens: turnBaseOut + curMsgOut })
1073
- } else if (ev?.type === 'message_delta' && ev.usage) {
1074
- curMsgOut = ev.usage.output_tokens ?? curMsgOut
1075
- emit({ kind: 'thinking_tokens', tokens: turnBaseOut + curMsgOut })
1076
- }
1077
- break
1078
- }
1079
- case 'result':
1080
- clearResilienceDeadline()
1081
- if (m.session_id) sessionId = m.session_id
1082
- // Bad resume target: the CLI can't find the session we tried to resume — a forked
1083
- // id that was never persisted, or a pruned transcript. It surfaces as an is_error
1084
- // RESULT (subtype error_during_execution), NOT a thrown error, so it bypasses the
1085
- // catch below and used to loop forever on the dead id (Max 2026-07-02, sid 6db6c1e3
1086
- // stuck across restarts + model switches). Drop the resume for good, re-run FRESH
1087
- // on the current model, and re-deliver the in-flight turn so nothing is lost.
1088
- if (m.is_error && Array.isArray(m.errors) && m.errors.some((x) => /no conversation found/i.test(String(x))) && (sessionId || persistedSessionId) && restartCount < RESTART_MAX) {
1089
- restartCount++
1090
- const wasActive = turnActive
1091
- turnActive = false
1092
- sessionId = null; persistedSessionId = null // never resume the dead id again
1093
- emit({ kind: 'note', text: 'previous session not found — continuing on a fresh one' })
1094
- const oldInput = input
1095
- input = makeInputStream()
1096
- try { oldInput.end() } catch { /* noop */ }
1097
- const replay = wasActive ? lastTurnText : null
1098
- restartTimer = setTimeout(() => {
1099
- restartTimer = null
1100
- if (closed) return
1101
- runQuery()
1102
- if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }]) }
1103
- }, 300)
1104
- break
1105
- }
1106
- // Swallow the interrupt's redundant teardown results (see `interrupting` above):
1107
- // abort() already emitted the one canonical aborted boundary. `success` (and any
1108
- // non-teardown subtype) always emits; only the aborted/error_during_execution pair
1109
- // the interrupt churns out is dropped, and only inside the timer-bounded window.
1110
- // This guard MUST precede resilient `is_error` handling: the second teardown echo
1111
- // is itself an is_error result and may arrive after the next turn has installed a
1112
- // new controller. Letting it reach that controller would retry the wrong prompt.
1113
- if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) {
1114
- // A late teardown echo from the stopped turn may arrive after the
1115
- // next turn is accepted. Swallow the echo, but only mutate liveness
1116
- // while the stopped revision still owns the lane.
1117
- if (resilienceTurnRevision === interruptingRevision) turnActive = false
1118
- break
1119
- }
1120
- // Some SDK transport failures arrive as an error result instead of a
1121
- // thrown iterator error. Treat them identically, without serializing
1122
- // `m.errors` or `m.result` into the room event stream.
1123
- if (m.is_error && turnResilience) {
1124
- if (resilienceRetryPending) break
1125
- turnResilience.failure({ message: Array.isArray(m.errors) ? m.errors.join(' ') : m.result, status: m.status ?? m.statusCode, code: m.code })
1126
- turnActive = false
1127
- retryResilientTurn()
1128
- break
1129
- }
1130
- turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
1131
- turnActive = false // turn settled → stall watchdog stands down
1132
- // A model switch requested mid-turn was deferred — apply it now the turn is done.
1133
- // Defer to the next tick so recreateForSwitch (which awaits THIS loop's qDone)
1134
- // doesn't await itself; the setTimeout runs after this iterator yields.
1135
- if (pendingSwitch) { pendingSwitch = false; setTimeout(() => { if (!closed) recreateForSwitch() }, 0) }
1136
- if (m.subtype === 'success') { restartCount = 0; if (sessionId) persistedSessionId = sessionId } // ONLY a real success refills the
1137
- if (m.subtype === 'success') { turnResilience?.success(); turnResilience = null }
1138
- // auto-restart budget (else a flapping connection that lands one aborted turn between
1139
- // drops refills every cycle past RESTART_MAX) — and marks this session id durably
1140
- // persisted so a later model-switch re-create can safely resume it.
1141
- forceStopped = false
1142
- stallRetried = false // a turn settled → the next turn gets a fresh auto-retry budget
1143
- // Ground-truth the model from THIS turn's modelUsage (the models actually billed
1144
- // this turn), not the init/system messages — a RESUMED session REPLAYS the prior
1145
- // transcript's init messages, so curModel got polluted back to the pre-switch model
1146
- // even though the turn really ran on the switched one (Max 2026-07-02: chip showed
1147
- // Opus while inference was Fable). Pick the model that did the most generating.
1148
- if (m.modelUsage && typeof m.modelUsage === 'object') {
1149
- let best = null, bestOut = -1
1150
- for (const [mid, u] of Object.entries(m.modelUsage)) {
1151
- const out = (u?.outputTokens ?? 0)
1152
- if (out > bestOut) { bestOut = out; best = mid }
1153
- }
1154
- if (best) curModel = best
1155
- }
1156
- // resultText: the SDK's final assistant text, clipped. The bridge folds it
1157
- // into the `turn-done` push body ("<lane> — done: <summary>") so a banner
1158
- // says what happened, not just that something did. Clipped + whitespace-
1159
- // collapsed here so neither the broadcast frame nor the persisted log grows
1160
- // by a full turn's prose. Absent on aborted/error results — nothing to say.
1161
- { const rt = typeof m.result === 'string' ? m.result.replace(/\s+/g, ' ').trim().slice(0, 200) : ''
1162
- emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0, resultText: rt || null }) }
1163
- // Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
1164
- // the window, generate one from the last assistant reply. (Skip aborted turns.)
1165
- if (sugTimer) clearTimeout(sugTimer)
1166
- if (m.subtype !== 'aborted') sugTimer = setTimeout(() => { sugTimer = null; if (!sawSuggestion && !closed) haikuSuggest() }, SUGGEST_FALLBACK_MS)
1167
- // Surface a usage/context meter (chrome, not a transcript line). The
1168
- // context window % comes from the control request; cost is cumulative.
1169
- ;(async () => {
1170
- let ctx = null
1171
- try {
1172
- const c = await q?.getContextUsage?.()
1173
- if (c) { ctx = correctContext({ used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }); if (c.model) curModel = c.model }
1174
- } catch { /* control req may be unavailable */ }
1175
- const u = m.usage || {}
1176
- emit({ kind: 'usage', costUsd: m.total_cost_usd ?? null, tokens: (u.input_tokens || 0) + (u.output_tokens || 0), ctx })
1177
- })()
1178
- break
1179
- case 'prompt_suggestion':
1180
- // Claude Code's own predicted next prompt (promptSuggestions opt-in).
1181
- // Arrives once per turn AFTER `result`; surfaced verbatim as ephemeral
1182
- // chrome (a ghost-text chip in the room composer), never persisted.
1183
- if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } // built-in won — cancel the Haiku fallback
1184
- if (m.suggestion) { sawSuggestion = true; emit({ kind: 'suggestion', text: m.suggestion }) }
1185
- break
1186
- default:
1187
- break
1188
- }
1189
- }
1190
- } catch (e) {
1191
- clearResilienceDeadline()
1192
- if (closed) return
1193
- // This query was intentionally aborted (a model switch superseded it, or the
1194
- // session is ending) — not a real error. Stay silent; the re-create owns what's next.
1195
- if (myAc.signal.aborted) return
1196
- const msg = e?.message || String(e)
1197
- // Enabled custom-provider turns have a strict, per-turn controller. It
1198
- // classifies the raw transport error locally, then either admits one exact
1199
- // replay before any output or terminates without putting provider details in
1200
- // the room. The legacy path below is intentionally untouched when disabled.
1201
- if (turnResilience && turnActive) {
1202
- turnResilience.failure({ message: msg, status: e?.status ?? e?.statusCode, code: e?.code })
1203
- turnActive = false
1204
- retryResilientTurn()
1205
- return
1206
- }
1207
- // onEvent is also the bridge's synchronous busy-edge sampling point. The
1208
- // query has already ended here, so publish the failure only after clearing
1209
- // the turn. Otherwise a non-recoverable SDK error leaves the roster on its
1210
- // last `busy: true` announce until a tab refocus asks for fresh state.
1211
- turnActive = false
1212
- emit({ kind: 'error', message: msg })
1213
- // Auto-restart a transient upstream stream drop: re-run query() with
1214
- // resume so the conversation continues. Bounded by RESTART_MAX (reset on
1215
- // each successful result) so a persistent outage can't loop forever; a
1216
- // fresh input stream is needed because the throw killed the old iterator.
1217
- if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
1218
- restartCount++
1219
- observeRecovery('stream_reconnect', { failureClass: 'network' })
1220
- emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
1221
- // End the OLD input stream before swapping — else its generator leaks
1222
- // (suspended forever) and a turn pushed into it after the throw is silently
1223
- // lost (push returns false on an ended stream, so sendTurn can surface it).
1224
- const oldInput = input
1225
- input = makeInputStream()
1226
- try { oldInput.end() } catch { /* noop */ }
1227
- // Track the timer so end() can cancel it, and re-check `closed` at fire time
1228
- // so a session torn down during the backoff doesn't spawn a zombie SDK child.
1229
- restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
1230
- return
1231
- }
1232
- // Resume target vanished (a forked session id that was never persisted, or a pruned
1233
- // transcript): drop the resume and re-run FRESH so the turn/switch still lands instead
1234
- // of hard-failing. Bounded by RESTART_MAX. Loses only the resumed history, not the turn.
1235
- if (/no conversation found/i.test(msg) && sessionId && restartCount < RESTART_MAX) {
1236
- restartCount++
1237
- sessionId = null; persistedSessionId = null
1238
- const oldInput = input
1239
- input = makeInputStream()
1240
- try { oldInput.end() } catch { /* noop */ }
1241
- restartTimer = setTimeout(() => { restartTimer = null; if (!closed) runQuery() }, 300)
1242
- return
1243
- }
1244
- // Non-recoverable, or budget exhausted: mark done so sendTurn stops
1245
- // silently queuing into a dead stream. The bridge's onEvent self-heal
1246
- // still fires for "No conversation found" (it reopens fresh).
1247
- closed = true
1248
- } finally {
1249
- ac.signal.removeEventListener('abort', onParentAbort)
1250
- if (qAc === myAc) qAc = null
1251
- // Await the SDK query's disposal so the underlying CLI PROCESS is actually dead
1252
- // before qDone resolves — otherwise a model-switch re-create races the dying process
1253
- // on the session file lock and the resumed query keeps the OLD model (proven 2026-07-02;
1254
- // q.return() waits on the transport exit, bounded internally to ~2s).
1255
- try { await q?.return?.() } catch { /* already disposed */ }
1256
- if (qDoneResolve) { qDoneResolve(); qDoneResolve = null }
1257
- }
1258
- }
1259
- // Re-create the query resuming the SAME session on the switched model (opts.model).
1260
- // Used when a live setModel isn't possible (resumed session). Tears the current query
1261
- // fully down and AWAITS its exit before re-running, so the two processes never race on
1262
- // the session file lock (a concurrent re-create keeps the OLD model — proven 2026-07-02).
1263
- const recreateForSwitch = async () => {
1264
- if (closed || !started) return
1265
- const dying = qDone
1266
- const oldInput = input
1267
- input = makeInputStream() // fresh stream for the re-created query
1268
- if (qAc) { try { qAc.abort() } catch { /* noop */ } } // kill the current query's process
1269
- try { oldInput.end() } catch { /* noop */ }
1270
- try { await dying } catch { /* noop */ } // wait until it's actually dead
1271
- await sleep(300) // brief lock-release margin
1272
- // Resume the last DURABLY-PERSISTED session, not the live one: the aborted query may
1273
- // have forked to a session id that was never written to disk, and resuming that fails
1274
- // with "No conversation found" (Max 2026-07-02, sid 6db6c1e3). persistedSessionId is
1275
- // always resumable (the restore id, advanced on each success).
1276
- sessionId = persistedSessionId
1277
- if (!closed) runQuery() // relaunch on opts.model + resume
1278
- }
1279
- // Abort a wedged turn (no event, no throw — the SDK's `for await` blocked inside an
1280
- // internal retry, e.g. an Anthropic 529-overload window) and re-run it ONCE, preserving
1281
- // the prompt. Mirrors recreateForSwitch's teardown-then-relaunch (abort qAc → swap the
1282
- // input stream → await the dying query's disposal so the two processes never race on the
1283
- // session file lock → resume the durable id) and then RE-PUSHES lastTurnText so the turn
1284
- // continues instead of being lost. The stall error was already surfaced by the watchdog
1285
- // (emitRaw of stallEvent('retry', …)); this adds a 'note' once the fresh turn is armed.
1286
- // Bounded to one call per turn by stallRetried (set in the interval before we're called).
1287
- const retryStalledTurn = async (quietMs) => {
1288
- if (closed) return
1289
- stallRetried = true
1290
- const wasActive = turnActive
1291
- // NEVER replay a `/compact`. The abort below is what cancels an in-flight compaction, and
1292
- // re-pushing the command just feeds the next abort. stallDecision already stands down while
1293
- // `compacting` is armed, so this is the belt for the window where the flag was cleared by a
1294
- // mid-compaction error but lastTurnText is still the command.
1295
- const replay = wasActive && !isCompactTurn(lastTurnText) ? lastTurnText : null
1296
- turnActive = false // unblock between-turns updates while we tear down
1297
- const dying = qDone
1298
- const oldInput = input
1299
- input = makeInputStream() // fresh stream for the relaunched query
1300
- if (qAc) { try { qAc.abort() } catch { /* noop */ } } // unblock the wedged for-await → its catch disposes the process
1301
- try { oldInput.end() } catch { /* noop */ }
1302
- try { await dying } catch { /* noop */ } // wait until the old query is actually dead (avoids the lock race)
1303
- await sleep(300) // brief lock-release margin
1304
- sessionId = persistedSessionId // resume the durably-persisted id (recreateForSwitch invariant)
1305
- if (closed) return
1306
- runQuery()
1307
- if (replay != null) {
1308
- turnActive = true; lastEvtTs = Date.now(); stalledSent = false // re-arm liveness for the retried turn
1309
- // Match sendTurn's block shape: a slash command goes clean; a normal turn keeps the reminder.
1310
- input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }])
1311
- emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
1312
- }
1313
- }
1314
- // A resilience retry has to wait for the failed Query's disposal: otherwise the
1315
- // replay races its session-file lock. Admission is deliberately before runQuery(),
1316
- // so a refused cap/circuit sends no second provider submission.
1317
- const retryResilientTurn = () => {
1318
- if (closed || abortPending || resilienceRetryPending) return
1319
- const retryingTurn = turnResilience
1320
- const retryingRevision = resilienceTurnRevision
1321
- const retryingBlocks = lastTurnBlocks
1322
- resilienceRetryPending = true
1323
- const dying = qDone
1324
- const oldInput = input
1325
- input = makeInputStream()
1326
- try { oldInput.end() } catch { /* noop */ }
1327
- restartTimer = setTimeout(async () => {
1328
- restartTimer = null
1329
- try { await dying } catch { /* disposal failure still fails closed below */ }
1330
- if (closed || !resilienceRetryPending || turnResilience !== retryingTurn || resilienceTurnRevision !== retryingRevision) return
1331
- if (!(await admitResilientSubmission())) {
1332
- resilienceRetryPending = false
1333
- finishResilientFailure()
1334
- return
1335
- }
1336
- if (closed || !resilienceRetryPending || turnResilience !== retryingTurn || resilienceTurnRevision !== retryingRevision) return
1337
- resilienceRetryPending = false
1338
- turnActive = true
1339
- lastEvtTs = Date.now()
1340
- stalledSent = false
1341
- runQuery()
1342
- // This is the exact previously accepted prompt block array, not a rebuilt
1343
- // string/reminder. It is only reached before assistant or tool output.
1344
- if (retryingBlocks) input.push(retryingBlocks)
1345
- armResilienceDeadline()
1346
- }, 0)
1347
- }
1348
- const finishResilientFailure = () => {
1349
- clearResilienceDeadline()
1350
- turnActive = false
1351
- turnResilience = null
1352
- // The failed Query is gone, but the lane remains recoverable for a later
1353
- // human turn. `started=false` ensures that turn creates a fresh Query rather
1354
- // than pushing into the ended stream.
1355
- started = false
1356
- emit({
1357
- kind: 'error',
1358
- message: 'The configured provider could not complete this turn. Nothing else was sent.',
1359
- recoverable: true,
1360
- })
1361
- }
1362
- // A custom resilient session defers its otherwise-eager query creation until a
1363
- // turn clears the injected admission gate. Disabled and built-in sessions retain
1364
- // their existing eager start and RESTART_MAX behavior.
1365
- if (!lazy && !resilienceEnabled) runQuery()
1366
-
1367
- return {
1368
- // A lazy (restored-idle) session cold-boots the query on its first turn. input.push
1369
- // is queue-backed, so the pushed turn buffers and runs once the query is ready.
1370
- sendTurn(text) { if (closed || abortPending || resilienceRetryPending) return false; if (!started && !admitColdStart()) return false; if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
1371
- const t = String(text)
1372
- const promptIndex = userPromptNo++
1373
- const thisTurnForceFull = forceFullReminder
1374
- forceFullReminder = /^\s*\/(?:compact|clear|reset)\b/i.test(t)
1375
- lastTurnReminder = /^\s*\//.test(t) ? null : roomReminder(t, { promptIndex, forceFull: thisTurnForceFull })
1376
- // Arm the compaction window BEFORE the push: from here until the `compaction` milestone
1377
- // (or the turn's result) the SDK is allowed to be silent for minutes without the stall
1378
- // watchdog aborting it. See turn-stall.mjs — aborting a compaction is what produced
1379
- // `AbortError: Compaction canceled.` in the room.
1380
- compacting = isCompactTurn(t)
1381
- // SDK 0.3.198 only recognizes a slash command (/compact, /clear, custom /cmds) when the
1382
- // user message is a SINGLE text block. Appending the per-turn ROOM NOW <system-reminder>
1383
- // as a 2nd block made 0.3.198 treat /compact as a plain turn → compaction silently
1384
- // no-op'd (Max, 2026-07-02; 0.3.185 tolerated the extra block, 0.3.198 tightened it —
1385
- // verified against the SDK: single-block → the compact command RUNS, two-block → not
1386
- // recognized). So a slash command goes CLEAN; conversational turns keep the reminder.
1387
- // Normal turns never start with "/" (composeAgentStdin prepends the preamble), and the
1388
- // web already routes "/"-prefixed input as a command (pane.jsx), so this matches intent.
1389
- lastTurnBlocks = /^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: lastTurnReminder }]
1390
- resilienceTurnRevision++
1391
- turnResilience = createTurnResilience()
1392
- if (turnResilience) {
1393
- const submittingTurn = turnResilience
1394
- const submittingRevision = resilienceTurnRevision
1395
- const submittingBlocks = lastTurnBlocks
1396
- // Keep the public sendTurn edge synchronous. The prompt is held locally until
1397
- // the asynchronous bridge cap admits it; a rejection never reaches the SDK.
1398
- void admitResilientSubmission().then((admitted) => {
1399
- // Stop or a newer human turn can land while cap admission is pending.
1400
- // In either case this exact turn no longer owns the submission edge.
1401
- if (closed || !turnActive || turnResilience !== submittingTurn || resilienceTurnRevision !== submittingRevision) return
1402
- if (!admitted) { finishResilientFailure(); return }
1403
- if (!started) runQuery({ admitted: true })
1404
- input.push(submittingBlocks)
1405
- armResilienceDeadline()
1406
- }).catch(() => {
1407
- if (!closed && turnActive && turnResilience === submittingTurn && resilienceTurnRevision === submittingRevision) finishResilientFailure()
1408
- })
1409
- } else {
1410
- if (!started) runQuery({ admitted: true })
1411
- input.push(lastTurnBlocks)
1412
- }
1413
- },
1414
- // Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
1415
- // lazily-restored idle terminals so they're ready before the user clicks them.
1416
- warm() { if (!resilienceEnabled && !started && !closed) runQuery() },
1417
- get started() { return started },
1418
- // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
1419
- // streaming control request (drives plan-mode behaviour SDK-side); the local
1420
- // `mode` drives our PreToolUse auto-approve policy. Echo so the room syncs.
1421
- async setMode(m) {
1422
- if (!MODES.has(m) || closed) return
1423
- mode = m // gate is authoritative immediately
1424
- scheduleSdkMode(m) // defer the SDK control request — a mode toggle can
1425
- // land while a perm card is pending (hook in flight),
1426
- // which is exactly the re-entrancy that jams it.
1427
- emit({ kind: 'mode', mode })
1428
- },
1429
- // /model is disabled in headless SDK — switch via the setModel control
1430
- // instead, and echo a note line so both drivers see the change.
1431
- async setModel(m) {
1432
- // Pin the switch into opts + curModel so every subsequent query re-run (auto-restart
1433
- // OR the re-create below) carries it.
1434
- opts.model = m
1435
- curModel = m
1436
- emit({ kind: 'note', text: `model → ${m}` })
1437
- if (!started || closed) return // not launched yet → the first runQuery uses opts.model
1438
- // Do NOT trust the live q.setModel: on a RESUMED streaming session it RESOLVES without
1439
- // error but is a silent no-op (the turn keeps running the old model — Max 2026-07-02).
1440
- // The only reliable switch is to RE-CREATE the query resuming the same session on the
1441
- // new model (query({resume, model}) honours it — proven). Defer a mid-turn switch to
1442
- // turn-end so we don't rip out an in-flight turn.
1443
- if (turnActive) { pendingSwitch = true; return }
1444
- await recreateForSwitch()
1445
- },
1446
- async setEffort(level) {
1447
- if (!effortLevels.has(level) || closed) return false
1448
- effort = level
1449
- opts.effort = level
1450
- emit({ kind: 'effort', level })
1451
- if (!started) return true
1452
- if (turnActive) { pendingSwitch = true; return true }
1453
- await recreateForSwitch()
1454
- return true
1455
- },
1456
- async listModels() {
1457
- try {
1458
- const ms = await q?.supportedModels?.()
1459
- const models = (ms || [])
1460
- .map((x) => ({ value: x?.value, displayName: x?.displayName, description: x?.description }))
1461
- .filter((x) => x.value)
1462
- // Structured event drives the picker; the note is a human-readable echo for the log.
1463
- if (models.length) emit({ kind: 'models', models })
1464
- emit({ kind: 'note', text: models.length ? `models: ${models.map((x) => x.value).join(', ')}` : 'no model list available' })
1465
- } catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
1466
- },
1467
- // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
1468
- // session alive for the next one. ac.abort() is teardown only (end()).
1469
- async abort() {
1470
- if (abortPending) return
1471
- abortPending = true
1472
- try {
1473
- // Swallow the SDK's redundant interrupt teardown (aborted + error_during_execution +
1474
- // re-init) — we emit the one canonical boundary below. Set BEFORE interrupt so the
1475
- // teardown results, which arrive async on the query loop, are caught; the timer bounds
1476
- // the window so a genuine later turn failure still surfaces.
1477
- const retryWasPending = resilienceRetryPending
1478
- const retryDisposal = retryWasPending ? qDone : null
1479
- if (turnActive || retryWasPending) {
1480
- resilienceTurnRevision++
1481
- // Keep the retry-pending ingress guard armed while Stop awaits the old
1482
- // process. The revision invalidates the retry itself; the flag prevents
1483
- // a new human turn from entering its reader-less replacement stream.
1484
- if (!retryWasPending) resilienceRetryPending = false
1485
- clearResilienceDeadline()
1486
- turnResilience?.failure({ aborted: true })
1487
- if (retryWasPending) {
1488
- clearTimeout(restartTimer)
1489
- restartTimer = null
1490
- started = false
1491
- }
1492
- interrupting = true
1493
- interruptingRevision = resilienceTurnRevision
1494
- clearTimeout(interruptTimer)
1495
- interruptTimer = setTimeout(() => { interrupting = false; interruptingRevision = null }, INTERRUPT_SWALLOW_MS)
1496
- }
1497
- try { await q?.interrupt?.() } catch { /* noop */ }
1498
- // A failed query may already be inside its bounded disposal while its
1499
- // retry owns no live SDK turn. Keep Stop's barrier open until that process
1500
- // is actually gone so a later human turn cannot race its session lock.
1501
- if (retryDisposal) { try { await retryDisposal } catch { /* already disposed */ } }
1502
- if (retryWasPending) resilienceRetryPending = false
1503
- // interrupt() stops the turn but emits NO terminal message. Without one the
1504
- // struct stream ends on a non-terminal event, so on resume the SDK treats the
1505
- // turn as INCOMPLETE and auto-continues it — the 2026-06-22 "zombie turn that
1506
- // resumes itself" storm. Emit a terminal result so the turn is marked DONE +
1507
- // persists across refresh. Guarded on turnActive to avoid a double-emit if the
1508
- // SDK already surfaced one for the interrupt. (restored from 0.7.49)
1509
- if (turnActive || retryWasPending) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
1510
- } finally {
1511
- abortPending = false
1512
- }
1513
- },
1514
- end() { closed = true; clearResilienceDeadline(); clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
1515
- get sessionId() { return sessionId },
1516
- get mode() { return mode },
1517
- get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
1518
- }
1519
- }
1
+ import{randomUUID as e}from"node:crypto";import{createRequire as t}from"node:module";import{readFileSync as o}from"node:fs";import{dirname as n,join as s}from"node:path";import{query as r}from"@anthropic-ai/claude-agent-sdk";import{sanitizeSession as i}from"./transcript-sanitize.mjs";import{reviewGatePreToolDecision as a}from"./flow-review-gate.mjs";import{crossPostNeedsCard as l}from"./cross-terminal.mjs";import{correctContext as c}from"./context-windows.mjs";import{normalizeClaudeCommandCatalog as d}from"./claude-command-catalog.mjs";import{evidenceForToolResult as u}from"./evidence-citations.mjs";import{THINKPOOL_CASCADE_RULE as p,THINKPOOL_REMOTE_DELIVERY_RULES as m,THINKPOOL_RUNTIME_AUTHORITY_RULE as h,THINKPOOL_RUNTIME_TURN_REMINDER as f,buildThinkPoolTurnGuidance as y,createRoomContextSelector as k,usesFullThinkPoolReminder as g}from"./thinkpool-room-prompt.mjs";import{stallDecision as w,stallEvent as b,isCompactTurn as v}from"./turn-stall.mjs";import{createSameTargetResilienceController as _,formatResilienceTrace as T}from"./provider-resilience.mjs";const S=t(import.meta.url);export async function claudeOneShot({prompt:e,model:t="claude-haiku-4-5",cwd:o,env:n,timeoutMs:s=8e3}={}){const i=new AbortController,a=setTimeout(()=>{try{i.abort()}catch{}},s);try{const s=r({prompt:e,options:{model:t,...o?{cwd:o}:{},env:n,maxTurns:1,permissionMode:"bypassPermissions",settingSources:[],strictMcpConfig:!0,mcpServers:{},abortController:i}});let a="";for await(const e of s){if("assistant"===e.type)for(const t of e.message?.content||[])"text"===t.type&&(a+=t.text);if("result"===e.type)break}return a.trim()}finally{clearTimeout(a)}}const E=(()=>{try{let e=n(S.resolve("@anthropic-ai/claude-agent-sdk"));for(let t=0;t<8;t++){try{const t=JSON.parse(o(s(e,"package.json"),"utf8"));if("@anthropic-ai/claude-agent-sdk"===t.name)return t.version}catch{}const t=n(e);if(t===e)break;e=t}}catch{}return"unknown"})(),N=/\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i,O=new Set(["Read","Grep","Glob","NotebookRead","TodoRead","LS"]),P=new Set(["WebFetch","WebSearch"]),R=new Set(["Edit","Write","MultiEdit","NotebookEdit","TodoWrite"]);export function classifyRisk(e,t){if("Bash"===e){const e=t&&(t.command??t.cmd)||"";return N.test(e)?"high":"medium"}return O.has(e)?"low":P.has(e)?"network":(R.has(e),"medium")}const A=/(^|\/)\.claude\/SESSIONS\/|(^|\/)CLAUDE\.md$/;export function isSafeDocWrite(e,t){if(!R.has(e))return!1;const o=t&&(t.file_path||t.notebook_path)||"";return A.test(o)}export function autoAllow({toolName:e,input:t,mode:o="default",alwaysAllow:n=new Set}){const s=classifyRisk(e,t);return"low"===s||"bypassPermissions"===o||"acceptEdits"===o&&R.has(e)&&"high"!==s||isSafeDocWrite(e,t)||n.has(`${e}:${s}`)}export function askUserQuestionHookOutput(e){const t=e&&"object"==typeof e?e.decision:e,o="string"==typeof t&&t.startsWith("answer:")?t.slice(7):"",n=e&&"object"==typeof e?e.responder:null,s="string"==typeof n?.name&&n.name.trim()?n.name.trim():"string"==typeof n?.id&&n.id?`room member ${n.id}`:"The user";return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:o?`${s} answered in the ThinkPool room — ${o}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`:"The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default."}}}export function askUserQuestionRegression(e,t){return Array.isArray(e)?e.includes("AskUserQuestion")?null:`[SDK-REGRESSION] agent SDK v${t||"unknown"} init tools[] is missing 'AskUserQuestion' — the multiple-choice question card is disabled. A caret SDK bump likely changed the canUseTool gate; pin a known-good SDK in bridge/package.json + republish.`:null}function D(){const e=[];let t=null,o=!1;return{stream:async function*(){for(;!o;)e.length?yield e.shift():await new Promise(e=>{t=e})}(),push:n=>!o&&(e.push({type:"user",message:{role:"user",content:n}}),t&&(t(),t=null),!0),end(){o=!0,t&&(t(),t=null)}}}const x=(e=[])=>e.map(e=>"text"===e.type?{type:"text",text:e.text}:"thinking"===e.type?{type:"thinking",text:e.thinking||"",reasoningKind:"thought"}:"tool_use"===e.type?{type:"tool_use",id:e.id,name:e.name,input:e.input}:{type:e.type}).filter(Boolean),I=new Set(["default","acceptEdits","plan","bypassPermissions"]),M=["You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room's features.",f,"TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.","WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.","BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A person-requested new or separate terminal uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list."].join(" ");export function startClaudeSession({cwd:t,model:o,effort:n="high",resume:s,env:f,mode:S="default",onEvent:N,requestPermission:O,mcpServers:P,crossPostGate:R,crossRoomPostGate:A,didSpawnTarget:U=null,terminalRolePrompt:C,rolePrompt:L,blockSubagents:W=!1,onSubmitPlan:F=null,onLaneDone:$=null,onReviewVerdict:j=null,reviewGate:q=null,lazy:G=!1,roomContext:B=null,suggest:H=!0,prepareCwd:K=null,admitStart:Q=null,resilienceObserver:Y=null,resilience:z=null}){const J=k(B);let V=0,X=!0;const Z=(e,{promptIndex:t=0,forceFull:o=!1}={})=>{const n=g({promptIndex:t,forceFull:o}),s=J({force:n}),r=String(C||"").trim();return`<system-reminder>\n${n&&r?`${r}\n\n`:""}${n?M:y({text:e,promptIndex:t,forceFull:o})}${s?`\n\n${s}`:""}\n</system-reminder>`},ee=new AbortController;let te=D(),oe=s||null,ne=s||null,se=null,re=null,ie=null,ae=!1,le=!1,ce=null,de=!1,ue=!1,pe=!1,me=null,he=I.has(S)?S:"default";const fe=new Set,ye=new Map,ke=new Set(["low","medium","high","xhigh","max"]);let ge=ke.has(n)?n:"high",we=0,be=0,ve=o||null,_e=null,Te=null,Se=Promise.resolve(),Ee=!1;const Ne=e=>new Promise(t=>setTimeout(t,e)),Oe=Math.max(3e4,parseInt(process.env.TP_STALL_MS,10)||9e4);let Pe=!1,Re=Date.now(),Ae=!1;const De=/connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i;let xe=0;const Ie=(e,t={})=>{try{Y?.({reason:e,restartCount:xe,...t})}catch{}},Me=!(!z?.policy?.enabled||!z?.providerId||"anthropic"===z.providerId);let Ue=null,Ce=null,Le=!1,We=0,Fe=!1,$e=null;const je=Math.max(3*Oe,parseInt(process.env.TP_FORCE_STOP_MS,10)||3e5);let qe=!1,Ge=!1;const Be=Math.max(je,parseInt(process.env.TP_COMPACT_FORCE_STOP_MS,10)||12e5);let He=!1,Ke=0;const Qe=O;O=async e=>{Ke++;try{return await(Qe?.(e))}finally{Ke=Math.max(0,Ke-1),Re=Date.now()}};let Ye=!1,ze=null,Je=null,Ve=!1,Xe="",Ze=null;const et=Math.max(1200,parseInt(process.env.TP_SUGGEST_FALLBACK_MS,10)||2500),tt=e=>{try{N?.(e)}catch{}},ot=e=>{Re=Date.now(),e&&"stalled"!==e.kind&&(Ae=!1),!e||"compaction"!==e.kind&&"result"!==e.kind&&"error"!==e.kind||(He=!1),tt(e)},nt=e=>{try{z?.onRecord?.(e)}catch{}"retrying"!==e?.outcome&&"cap_blocked"!==e?.outcome&&"circuit_open"!==e?.outcome||ot({kind:"note",text:T(e)})},st=()=>{if(null!=Ce){try{(z?.clearTimer||clearTimeout)(Ce)}catch{}Ce=null}},rt=()=>{if(st(),!Ue)return;const e=Number(z?.policy?.timeoutMs);if(!Number.isFinite(e)||e<=0)return;const t=z?.setTimer||setTimeout;Ce=t(()=>{if(Ce=null,!ae&&Ue&&Pe){Ue.failure({message:"timeout"}),Pe=!1;try{_e?.abort()}catch{}ft()}},e)},it=async()=>{if(!Ue)return!0;let e=null;try{e=await Ue.preflight()}catch{return!1}return!0===e?.admitted},at=setInterval(()=>{const e=Date.now()-Re,t=w({turnActive:Pe,awaitingUser:Ke,quietMs:e,stallMs:Oe,forceStopMs:je,stalledSent:Ae,stallRetried:Ge,compacting:He,compactForceStopMs:Be});if("none"===t)return;const o=b(t,e);if(o&&tt(o),"status"!==t){if("retry"===t){if(qe=!0,Ue){Ie("resilience_stall",{quietMs:e}),Ue.failure({message:"timeout"}),Pe=!1;try{_e?.abort()}catch{}return void ft()}return Ie("stall_replay",{quietMs:e}),void ht(e)}qe=!0,Pe=!1}else Ae=!0},5e3);at.unref?.();const lt=e=>{setTimeout(()=>{Promise.resolve(me?.setPermissionMode?.(e)).catch(t=>console.error(`[claude-session] setPermissionMode(${e}) failed:`,t?.message||t))},0)},ct={abortController:ee,permissionMode:he,hooks:{PreToolUse:[{hooks:[async t=>{const o=t.tool_name,n=t.tool_input,s=t.effort?.level;s&&s!==ge&&(ge=s,ot({kind:"effort",level:ge}));const r="Write"===o||"Edit"===o||"MultiEdit"===o||"NotebookEdit"===o;if(W&&r&&/(?:^|[/\\])FLOW_PLAN\.json$/i.test(n?.file_path||n?.notebook_path||"")){const e=n?.content??n?.new_string??"";let t={ok:!1,message:"Plan submission is not wired for this session."};try{t=F&&await F(e)||t}catch(e){t={ok:!1,message:`Plan submission failed: ${e?.message||e}`}}return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:t.message}}}if(W&&("Task"===o||"Agent"===o||"Bash"===o||r))return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Bash"===o?"Flow conductors do NOT run Bash — it pops a permission card and stalls the flow. Use the Grep tool (not `grep`/`rg`), the Glob tool (not `find`/`ls`), and Read (not `cat`/`head`) — those run with no prompt. Then DECOMPOSE and submit by Writing your task-graph JSON to the file FLOW_PLAN.json.":r?'Flow conductors do NOT write project or plan-document files. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). To SUBMIT your decomposition, use the Write tool with file_path "FLOW_PLAN.json" and content = your task-graph JSON ({"summary":"…","tasks":[…]}). That single Write IS how you submit — the room ingests it directly. Do not write any other file.':"Flow conductors do NOT spawn subagents — it stalls the flow on a permission card. Inspect the codebase yourself with Read/Grep/Glob (no prompt). Then DECOMPOSE and submit by Writing your task-graph JSON to the file FLOW_PLAN.json. Do not call Task/Agent again."}};if(j&&("Write"===o||"Edit"===o)&&/(?:^|[/\\])FLOW_REVIEW(\.[a-zA-Z0-9]+)?$/.test(n?.file_path||"")){let e={ok:!1,message:"verdict signal failed"};try{e=await j(n?.content??n?.new_string??"")||e}catch(t){e={ok:!1,message:`verdict signal failed: ${t?.message||t}`}}return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:e.message}}}if($&&("Write"===o||"Edit"===o)&&/(?:^|[/\\])FLOW_DONE(\.[a-zA-Z0-9]+)?$/.test(n?.file_path||"")){let e={ok:!1,message:"done signal failed"};try{e=await $(n?.content??n?.new_string??"")||e}catch(t){e={ok:!1,message:`done signal failed: ${t?.message||t}`}}return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:e.message}}}const i=a({reviewGate:q,toolName:o,toolInput:n});if(i)return i;if("mcp__thinkpool__read_terminal"===o)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (read-only ThinkPool cross-terminal read)."}};if(/^mcp__thinkpool__preview_(start|capture|inspect|stop)$/.test(o))return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (contained bridge-owned viewport preview)."}};if("mcp__thinkpool__submit_flow_plan"===o)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (Flow plan submission — broadcasts the task-graph for human approval)."}};if("mcp__thinkpool__list_sessions"===o||"mcp__thinkpool__read_session"===o)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (read-only ThinkPool cross-session read — your own rooms)."}};if("mcp__thinkpool__close_terminal"===o)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes)."}};if("mcp__thinkpool__spawn_terminal"===o)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Auto-approved (autonomous ThinkPool worker; bounded by room caps and isolated in its own worktree)."}};if("mcp__thinkpool__open_main_terminal"===o){const t=n?.mode||he;if("bypassPermissions"!==t||"bypassPermissions"===he)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:`Auto-approved (open a fresh ThinkPool terminal in ${t} mode — inherited, no escalation; bounded by room caps).`}};let s="deny";try{s=await(O?.({id:e(),toolName:o,input:{...n,mode:t},risk:"high"}))??"deny"}catch{s="deny"}const r="allow"===s;return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:r?"allow":"deny",permissionDecisionReason:r?"Bypass terminal creation approved in the ThinkPool room — the new terminal runs autonomously.":"Bypass terminal creation denied in the room — do not retry as bypass; open it without mode:bypassPermissions or ask what to do."}}}if("mcp__thinkpool__post_to_terminal"===o){const t=R?R():{ok:!0};if(!t.ok)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:t.reason||"Cross-terminal post not allowed right now."}};const s=!!U&&!!U(n?.terminal);if(!l({mode:he,spawnedByMe:s}))return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"bypassPermissions"===he?"Auto-approved (cross-terminal post in bypassPermissions mode — bypass means bypass).":"Auto-approved (cross-terminal post into a lane you spawned — parent→child orchestration, no per-post tap)."}};let r="deny";try{r=await(O?.({id:e(),toolName:o,input:n,risk:"high"}))??"deny"}catch{r="deny"}const i="allow"===r;return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:i?"allow":"deny",permissionDecisionReason:i?"Cross-terminal post approved in the ThinkPool room.":"Cross-terminal post denied in the room — do not retry; ask what to do instead."}}}if("mcp__thinkpool__post_to_session"===o){const t=A?A():{ok:!0};if(!t.ok)return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:t.reason||"Cross-room post not allowed right now."}};let s="deny";try{s=await(O?.({id:e(),toolName:o,input:n,risk:"high"}))??"deny"}catch{s="deny"}const r="allow"===s;return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:r?"allow":"deny",permissionDecisionReason:r?"Cross-room post approved in the sending room — the target room will be asked to accept it.":"Cross-room post denied in the room — do not retry; ask what to do instead."}}}if("ExitPlanMode"===o){if("plan"!==he)return lt(he),ot({kind:"mode",mode:he}),{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:`The ThinkPool room is in ${he} mode, not Plan — the user did not ask for a plan. Do NOT call ExitPlanMode; proceed and make the changes directly. Only present a plan if the user switches the room to Plan mode or explicitly asks.`}};let t="keep";try{t=await(O?.({id:e(),toolName:o,input:n,risk:"plan",plan:n?.plan||""}))??"keep"}catch{t="keep"}if("run"===t||"accept"===t){const e="accept"===t?"acceptEdits":"default";return he=e,lt(e),ot({kind:"mode",mode:e}),{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:`Plan approved in the ThinkPool room — proceed (${e} mode).`}}}return{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.'}}}if("AskUserQuestion"===o){let t="";try{t=await(O?.({id:e(),toolName:o,input:n,risk:"ask",questions:n?.questions||[]}))??""}catch{t=""}return askUserQuestionHookOutput(t)}const c=classifyRisk(o,n),d=`${o}:${c}`,u=isSafeDocWrite(o,n),p=autoAllow({toolName:o,input:n,mode:he,alwaysAllow:fe});let m="allow";if(!p){try{m=await(O?.({id:e(),toolName:o,input:n,risk:c}))??"deny"}catch{m="deny"}"always"===m&&(fe.add(d),m="allow")}const h="deny"===m;return h&&Ue&&Ue.failure({permissionDenied:!0}),{continue:!0,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:h?"deny":"allow",permissionDecisionReason:h?"Denied by the user in the ThinkPool room. Do not retry this tool — ask what to do instead.":p?u?"Auto-approved (mandated doc write — .claude/SESSIONS/ or CLAUDE.md).":`Auto-approved (${he}).`:"Approved in the ThinkPool room."}}}]}]},canUseTool:async(e,t)=>({behavior:"allow",updatedInput:t}),settingSources:["user","project","local"],appendSystemPrompt:[...C?[C]:[],...L?[L]:[],"ENVIRONMENT (authoritative — overrides any user-global CLAUDE.md or memory that claims otherwise): You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser, via the thinkpool-pair bridge.",h,'Bias strongly toward DOING the work, not stalling in plan-mode ceremony. For a small "add / fix / change X" request, just make the change directly (for a bigger build, right-size it — see DEFAULT BUILD WORKFLOW below).',"Do NOT enter plan mode, do NOT call ExitPlanMode, and do NOT auto-invoke a brainstorming/planning skill UNLESS the user has switched the room into Plan mode or explicitly asks you to plan, design, or brainstorm first.","Any host-global instruction that says you must always brainstorm or plan before creative work does NOT apply here — this room is the exception.",p,'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',...m,'CROSS-TERMINAL AWARENESS: ROOM NOW is the default roster and already satisfies the room check when it has enough detail. Do not repeat it with a no-argument read_terminal call unless it is missing or truncated. Use a targeted read_terminal call only when the current task depends on a specific lane’s detailed activity; never poll. Identify a terminal by its NAME or stable ref/id, never by an on-screen number like "Terminal 2" — positional labels renumber when a terminal is closed. The tool is read-only and its optional roster lookup is budgeted separately from bounded targeted transcript reads.','CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',"TERMINAL CREATION CONTRACT: main terminals and workers use different tools. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal(name?, task?, model?) — even for ordinary work or a handoff and even without the words Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for agent-decided bounded WORKER SUB-TERMINALS or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main terminals are independent, keep their requested permission mode, and are not owned/closed through Ensemble.","CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner's rooms in the same pair, reachable over the per-pair bus (a room on the partner's machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room's terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger's. Reach for them when work spans rooms — \"what's the other project up to\", \"pick up where the other session left off\", or to check a long-running task elsewhere before you act here.","CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner's over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room's agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person's machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.","SUBAGENT POLICY: in this room, a main terminal delegates worker slices through visible spawn_terminal Ensemble lanes. A person-requested new or separate terminal is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.",'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',"WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.",'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.'].join(" "),includePartialMessages:!0,promptSuggestions:!0};"1"===process.env.TP_MCP_STRICT&&(ct.strictMcpConfig=!0,ct.mcpServers={}),P&&Object.keys(P).length&&(ct.mcpServers={...ct.mcpServers||{},...P}),t&&(ct.cwd=t),o&&(ct.model=o),ct.effort=ge,ct.env={...f||process.env,CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION:"1"};const dt=async()=>{if(!H)return;const e=(Xe||"").trim().slice(-1500);if(e&&!ae)try{let o=await claudeOneShot({prompt:`You are predicting the user's NEXT chat message in a live coding session, to prefill their composer. Given the assistant's latest reply below, output the single most likely next user message — short and natural (often just "proceed", "go with option A", "yes do that", or a brief follow-up). One line, <=12 words, imperative, no preamble, no quotes, no markdown.\n\nAssistant's latest reply:\n"""\n${e}\n"""\n\nNext user message:`,model:"claude-haiku-4-5",cwd:t,env:ct.env,timeoutMs:8e3});o=o.trim().split("\n")[0].replace(/^["'`]+|["'`]+$/g,"").trim().slice(0,140),!o||Ve||ae||/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(o)||(ot({kind:"suggestion",text:o,source:"haiku"}),process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(o)}\n`))}catch{}},ut=()=>{const e="function"==typeof Q?Q():{ok:!0};return!1!==e?.ok||(ot({kind:"error",message:e.reason||"Host memory is critically low. This agent runtime was not started.",recoverable:!0}),!1)},pt=async({admitted:e=!1}={})=>{if(!le&&!e&&!ut())return!1;if(null==ce&&(ce=Date.now()),le=!0,Te)try{Te()}catch{}Se=new Promise(e=>{Te=e});const n=new AbortController;_e=n;const a=()=>{try{n.abort()}catch{}};ee.signal.addEventListener("abort",a,{once:!0});try{if(oe){const e=i(t||process.cwd(),oe);e.blocks&&process.stderr.write(`\n ◆ healed ${e.blocks} malformed tool block(s) in the transcript before resume.\n`)}me=r({prompt:te.stream,options:{...ct,abortController:n,...oe?{resume:oe}:{}}});for await(const e of me){if(ae)break;switch(e.type){case"system":if("thinking_tokens"===e.subtype){we+be===0&&ot({kind:"thinking_tokens",tokens:e.estimated_tokens,delta:e.estimated_tokens_delta});break}if("compact_boundary"===e.subtype){X=!0,ot({kind:"compaction",trigger:e.compact_metadata?.trigger||"auto",preTokens:e.compact_metadata?.pre_tokens??null}),(async()=>{try{const e=await(me?.getContextUsage?.());e&&(ot({kind:"usage",ctx:c({used:e.totalTokens,max:e.maxTokens,pct:Math.round(e.percentage),model:e.model})}),e.model&&(ve=e.model))}catch{}})();break}if(e.session_id&&(oe=e.session_id),de||(de=!0,process.stderr.write(`\n ◆ session ready in ${Date.now()-ce}ms — MCP ${"1"===process.env.TP_MCP_STRICT?"OFF":"on"}${s?", resume":", fresh"}.\n`)),ve=ct.model||e.model||o||ve,ot({kind:"system",sessionId:oe,model:ct.model||e.model||o||null,commands:d({slashCommands:e.slash_commands,skills:e.skills})}),!pe&&Array.isArray(e.tools)){pe=!0;const t=askUserQuestionRegression(e.tools,E);t&&(process.stderr.write(`\n ⚠⚠ ${t}\n`),ot({kind:"error",message:t}))}ue||(ue=!0,Promise.resolve(me?.supportedModels?.()).then(e=>{const t=(e||[]).map(e=>({value:e?.value,displayName:e?.displayName,description:e?.description})).filter(e=>e.value);t.length&&ot({kind:"models",models:t})}).catch(()=>{}));break;case"assistant":st(),Ue?.visibleOutput();for(const t of e.message?.content||[])"tool_use"===t?.type&&t.id&&ye.set(t.id,{at:Date.now(),name:t.name,input:t.input});{const t=(e.message?.content||[]).filter(e=>"text"===e?.type).map(e=>e.text).join("\n").trim();t&&(Xe=t)}ot({kind:"assistant",blocks:x(e.message?.content),parentToolUseId:e.parent_tool_use_id||null});break;case"user":for(const t of e.message?.content||[])if("tool_result"===t?.type){st(),Ue?.visibleOutput({tool:!0});const o=ye.get(t.tool_use_id);null!=o&&ye.delete(t.tool_use_id);const n=u(o?.name,t.content);ot({kind:"tool_result",toolUseId:t.tool_use_id,content:t.content,...n?{evidence:n}:{},isError:!!t.is_error,durationMs:null!=o?.at?Date.now()-o.at:void 0,parentToolUseId:e.parent_tool_use_id||null})}break;case"stream_event":{const t=e.event;"message_start"===t?.type?(we+=be,be=t.message?.usage?.output_tokens||0,ot({kind:"thinking_tokens",tokens:we+be})):"message_delta"===t?.type&&t.usage&&(be=t.usage.output_tokens??be,ot({kind:"thinking_tokens",tokens:we+be}));break}case"result":if(st(),e.session_id&&(oe=e.session_id),e.is_error&&Array.isArray(e.errors)&&e.errors.some(e=>/no conversation found/i.test(String(e)))&&(oe||ne)&&xe<3){xe++;const e=Pe;Pe=!1,oe=null,ne=null,ot({kind:"note",text:"previous session not found — continuing on a fresh one"});const t=te;te=D();try{t.end()}catch{}const o=e?se:null;$e=setTimeout(()=>{$e=null,ae||(pt(),null!=o&&(Pe=!0,Re=Date.now(),Ae=!1,te.push([{type:"text",text:o},{type:"text",text:re||Z(o,{forceFull:!0})}])))},300);break}if(Ye&&("aborted"===e.subtype||"error_during_execution"===e.subtype)){We===ze&&(Pe=!1);break}if(e.is_error&&Ue){if(Le)break;Ue.failure({message:Array.isArray(e.errors)?e.errors.join(" "):e.result,status:e.status??e.statusCode,code:e.code}),Pe=!1,ft();break}if(we=0,be=0,Pe=!1,Ee&&(Ee=!1,setTimeout(()=>{ae||mt()},0)),"success"===e.subtype&&(xe=0,oe&&(ne=oe)),"success"===e.subtype&&(Ue?.success(),Ue=null),qe=!1,Ge=!1,e.modelUsage&&"object"==typeof e.modelUsage){let t=null,o=-1;for(const[n,s]of Object.entries(e.modelUsage)){const e=s?.outputTokens??0;e>o&&(o=e,t=n)}t&&(ve=t)}{const t="string"==typeof e.result?e.result.replace(/\s+/g," ").trim().slice(0,200):"";ot({kind:"result",subtype:e.subtype,sessionId:oe,model:ve,costUsd:e.total_cost_usd,usage:e.usage,numTurns:e.num_turns,durationMs:e.duration_ms??null,denials:Array.isArray(e.permission_denials)?e.permission_denials.length:0,resultText:t||null})}Ze&&clearTimeout(Ze),"aborted"!==e.subtype&&(Ze=setTimeout(()=>{Ze=null,Ve||ae||dt()},et)),(async()=>{let t=null;try{const e=await(me?.getContextUsage?.());e&&(t=c({used:e.totalTokens,max:e.maxTokens,pct:Math.round(e.percentage),model:e.model}),e.model&&(ve=e.model))}catch{}const o=e.usage||{};ot({kind:"usage",costUsd:e.total_cost_usd??null,tokens:(o.input_tokens||0)+(o.output_tokens||0),ctx:t})})();break;case"prompt_suggestion":Ze&&(clearTimeout(Ze),Ze=null),e.suggestion&&(Ve=!0,ot({kind:"suggestion",text:e.suggestion}))}}}catch(e){if(st(),ae)return;if(n.signal.aborted)return;const t=e?.message||String(e);if(Ue&&Pe)return Ue.failure({message:t,status:e?.status??e?.statusCode,code:e?.code}),Pe=!1,void ft();if(Pe=!1,ot({kind:"error",message:t}),De.test(t)&&xe<3){xe++,Ie("stream_reconnect",{failureClass:"network"}),ot({kind:"note",text:`connection dropped — reconnecting (${xe}/3)`});const e=te;te=D();try{e.end()}catch{}return void($e=setTimeout(()=>{$e=null,ae||pt()},Math.min(1e3*xe,4e3)))}if(/no conversation found/i.test(t)&&oe&&xe<3){xe++,oe=null,ne=null;const e=te;te=D();try{e.end()}catch{}return void($e=setTimeout(()=>{$e=null,ae||pt()},300))}ae=!0}finally{ee.signal.removeEventListener("abort",a),_e===n&&(_e=null);try{await(me?.return?.())}catch{}Te&&(Te(),Te=null)}},mt=async()=>{if(ae||!le)return;const e=Se,t=te;if(te=D(),_e)try{_e.abort()}catch{}try{t.end()}catch{}try{await e}catch{}await Ne(300),oe=ne,ae||pt()},ht=async e=>{if(ae)return;Ge=!0;const t=Pe&&!v(se)?se:null;Pe=!1;const o=Se,n=te;if(te=D(),_e)try{_e.abort()}catch{}try{n.end()}catch{}try{await o}catch{}await Ne(300),oe=ne,ae||(pt(),null!=t&&(Pe=!0,Re=Date.now(),Ae=!1,te.push(/^\s*\//.test(t)?[{type:"text",text:t}]:[{type:"text",text:t},{type:"text",text:re||Z(t,{forceFull:!0})}]),tt({kind:"note",text:"retrying the stalled turn on a fresh connection"})))},ft=()=>{if(ae||Fe||Le)return;const e=Ue,t=We,o=ie;Le=!0;const n=Se,s=te;te=D();try{s.end()}catch{}$e=setTimeout(async()=>{$e=null;try{await n}catch{}if(!ae&&Le&&Ue===e&&We===t)return await it()?void(!ae&&Le&&Ue===e&&We===t&&(Le=!1,Pe=!0,Re=Date.now(),Ae=!1,pt(),o&&te.push(o),rt())):(Le=!1,void yt())},0)},yt=()=>{st(),Pe=!1,Ue=null,le=!1,ot({kind:"error",message:"The configured provider could not complete this turn. Nothing else was sent.",recoverable:!0})};return G||Me||pt(),{sendTurn(e){if(ae||Fe||Le)return!1;if(!le&&!ut())return!1;if(!le&&K)try{const e=K();e&&(t=e,ct.cwd=e)}catch{}Pe=!0,Ve=!1,se=String(e),Ze&&(clearTimeout(Ze),Ze=null),Re=Date.now(),Ae=!1,Ge=!1,qe=!1;const n=String(e),s=V++,r=X;if(X=/^\s*\/(?:compact|clear|reset)\b/i.test(n),re=/^\s*\//.test(n)?null:Z(n,{promptIndex:s,forceFull:r}),He=v(n),ie=/^\s*\//.test(n)?[{type:"text",text:n}]:[{type:"text",text:n},{type:"text",text:re}],We++,Ue=(()=>{if(!Me)return null;try{const e=_({runtime:"claude",providers:z.providers||[],providerId:z.providerId,model:z.model||ct.model||o,requestedModel:z.requestedModel||z.model||ct.model||o,policy:z.policy,bridgeHostId:z.bridgeHostId,circuit:z.circuit,capGate:z.capGate,traceId:z.traceId,turnRev:"function"==typeof z.turnRev?z.turnRev():z.turnRev,onRecord:nt});return e.start(),e}catch{return null}})(),Ue){const e=Ue,t=We,o=ie;it().then(n=>{!ae&&Pe&&Ue===e&&We===t&&(n?(le||pt({admitted:!0}),te.push(o),rt()):yt())}).catch(()=>{!ae&&Pe&&Ue===e&&We===t&&yt()})}else le||pt({admitted:!0}),te.push(ie)},warm(){Me||le||ae||pt()},get started(){return le},async setMode(e){I.has(e)&&!ae&&(he=e,lt(e),ot({kind:"mode",mode:he}))},async setModel(e){ct.model=e,ve=e,ot({kind:"note",text:`model → ${e}`}),le&&!ae&&(Pe?Ee=!0:await mt())},setEffort:async e=>!(!ke.has(e)||ae||(ge=e,ct.effort=e,ot({kind:"effort",level:e}),le&&(Pe?(Ee=!0,0):(await mt(),0)))),async listModels(){try{const e=(await(me?.supportedModels?.())||[]).map(e=>({value:e?.value,displayName:e?.displayName,description:e?.description})).filter(e=>e.value);e.length&&ot({kind:"models",models:e}),ot({kind:"note",text:e.length?`models: ${e.map(e=>e.value).join(", ")}`:"no model list available"})}catch{ot({kind:"note",text:"usage: /model <name>"})}},async abort(){if(!Fe){Fe=!0;try{const e=Le,t=e?Se:null;(Pe||e)&&(We++,e||(Le=!1),st(),Ue?.failure({aborted:!0}),e&&(clearTimeout($e),$e=null,le=!1),Ye=!0,ze=We,clearTimeout(Je),Je=setTimeout(()=>{Ye=!1,ze=null},6e3));try{await(me?.interrupt?.())}catch{}if(t)try{await t}catch{}e&&(Le=!1),(Pe||e)&&(Pe=!1,ot({kind:"result",subtype:"aborted",sessionId:oe}))}finally{Fe=!1}}},end(){ae=!0,st(),clearInterval(at),clearTimeout(Je),clearTimeout($e),Ze&&clearTimeout(Ze),te.end();try{ee.abort()}catch{}},get sessionId(){return oe},get mode(){return he},get turnActive(){return Pe}}}