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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/claude-session.mjs
CHANGED
|
@@ -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}}}
|