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
|
@@ -1,187 +1 @@
|
|
|
1
|
-
// Shared delivery contract for structured agents running inside ThinkPool Code.
|
|
2
|
-
// Claude receives this in its system prompt; all runtimes receive a compact
|
|
3
|
-
// per-turn invariant plus the full contract at session start, after a reset, and
|
|
4
|
-
// every fifth user prompt. Keep runtime-specific mechanics in their own drivers;
|
|
5
|
-
// the Cascade contract below is a cross-runtime ThinkPool workflow
|
|
6
|
-
// about available capabilities and how finished work reaches the people.
|
|
7
|
-
// One canonical intent → capability router for every structured runtime. Tool
|
|
8
|
-
// schemas still carry their detailed argument contracts; this compact registry
|
|
9
|
-
// tells a fresh agent WHEN ThinkPool expects each capability without requiring
|
|
10
|
-
// the people in the room to know a tool name or summon word.
|
|
11
|
-
import { THINKPOOL_CAPABILITY_ROUTES, THINKPOOL_PROMPT_BUNDLE, thinkPoolCapabilityContract } from './thinkpool-prompt-contracts.mjs'
|
|
12
|
-
import { buildContextManifest, formatRoomContextProjection } from './context-contract.mjs'
|
|
13
|
-
|
|
14
|
-
export { THINKPOOL_CAPABILITY_ROUTES, THINKPOOL_PROMPT_BUNDLE }
|
|
15
|
-
|
|
16
|
-
export const THINKPOOL_ROUTED_TOOLS = Object.freeze([
|
|
17
|
-
...new Set(THINKPOOL_CAPABILITY_ROUTES.flatMap((route) => route.tools)),
|
|
18
|
-
])
|
|
19
|
-
|
|
20
|
-
export function renderThinkPoolCapabilityRoutes(routes = THINKPOOL_CAPABILITY_ROUTES) {
|
|
21
|
-
return routes.map((route) => `${route.id}: ${route.rule}`).join(' ')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const THINKPOOL_RUNTIME_AUTHORITY_RULE = thinkPoolCapabilityContract('runtime-authority').globalPrompt
|
|
25
|
-
|
|
26
|
-
export const THINKPOOL_TURN_SCOPE_RULE = thinkPoolCapabilityContract('turn-scope-authority').globalPrompt
|
|
27
|
-
|
|
28
|
-
export const THINKPOOL_CASCADE_RULE = thinkPoolCapabilityContract('work-routing').expandedPrompt
|
|
29
|
-
|
|
30
|
-
export const THINKPOOL_AGENT_CONTRACT = [
|
|
31
|
-
THINKPOOL_TURN_SCOPE_RULE,
|
|
32
|
-
'THINKPOOL-FIRST OPERATING CONTRACT (authoritative): ThinkPool room capabilities are your normal operating surface, not optional enrichment. Before acting on every request, infer which exposed ThinkPool capabilities materially improve room visibility, coordination, delivery, or verification and use them without waiting for the people to know a tool name, magic word, or workflow.',
|
|
33
|
-
THINKPOOL_RUNTIME_AUTHORITY_RULE,
|
|
34
|
-
`DEFAULT ROUTING: ${renderThinkPoolCapabilityRoutes()}`,
|
|
35
|
-
THINKPOOL_CASCADE_RULE,
|
|
36
|
-
'DEFAULT DOES NOT MEAN GRATUITOUS: use only capabilities exposed to your current role and only when relevant. Honor explicit steering such as single-lane, no research, do not contact another room, or do not use a named ThinkPool feature. Metered and side-effecting capabilities still obey their stated offer, approval, and consent contracts.',
|
|
37
|
-
].join(' ')
|
|
38
|
-
|
|
39
|
-
export const THINKPOOL_DESIGN_INTERACTION_RULE = thinkPoolCapabilityContract('design-workspace').interactionPrompt
|
|
40
|
-
|
|
41
|
-
export const THINKPOOL_DESIGN_DELIVERY_RULE = `THINKPOOL DESIGN (mandatory for authored HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a source-backed Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. The interactive Design card/popup is the visible result: the renders remain required verification inputs, but when that card is displayed do not also surface duplicate inline PNGs. Surface desktop/mobile PNGs only when no interactive source-backed Design artifact is available. ${THINKPOOL_DESIGN_INTERACTION_RULE} When the requested Design surface is an existing product page or route, first build and capture the actual route at both viewports, then read its current source, styles, copy, fonts, and assets. Derive the editable Design artifact from that evidence and compare both artifact captures against the real route before delivery. Preserve the real page faithfully except for explicitly proposed edits—never hand-recreate it from memory, simplify it, replace it with generic mockup content, or label an approximation as the product. If a faithful editable artifact cannot be produced, surface the actual route captures and say that Design editing is unavailable for that surface. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.`
|
|
42
|
-
|
|
43
|
-
export const THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE = 'REMOTE ARTIFACT ACCESS (authoritative): ThinkPool room members may be reviewing from a phone and have only their existing ThinkPool session. Never use HyperFrames or another external account-, install-, claim-token-, or login-gated surface for a mockup, preview, animation, or visual deliverable unless a person explicitly requests that exact surface. Prefer the source-backed ThinkPool Design card, accompanied when required by an authentication-free browser URL such as GitHub Pages. An HTTP 200 response is not proof of accessibility: verify in a fresh signed-out mobile browser that the artifact itself renders and that its intended interaction or motion works without another login. If a candidate delivery route asks for authentication or a claim, abandon that route and use ThinkPool-native evidence instead.'
|
|
44
|
-
|
|
45
|
-
export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
|
|
46
|
-
'REMOTE USER (authoritative default for ThinkPool Code rooms): unless a person explicitly says they are at the host machine, assume everyone driving this room is remote — possibly on a phone — with no terminal and no access to the host filesystem. Never ask them to run a local command, open a local file, or "go check" something on the machine. Anything host-side, you run yourself and show the result in the room. Only suggest actions they can actually do from the room UI or a browser.',
|
|
47
|
-
'LINKS & ARTIFACTS: a local filesystem path, file:// URL, localhost/127.0.0.1 address, or host-only preview is useless to a remote room. Every link you surface must be reachable by the people in the room.',
|
|
48
|
-
THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE,
|
|
49
|
-
'Whenever you produce HTML or shareable markup — a demo, mockup, preview, report, or page — publish it to a browser-renderable GitHub-shareable URL, normally GitHub Pages (or an equivalent URL that actually renders; raw.githubusercontent.com serves HTML as plain text). Give the room that shareable URL in addition to the Thinkpool Design card, never instead of it and never only as a local path. If you cannot publish it, say so instead of falling back to a host-only path.',
|
|
50
|
-
'SHOW VISUAL WORK: whenever you build, change, or fix anything visual, show the result in the room. A source-backed Thinkpool Design card/popup is already the interactive visible result, so do not additionally surface duplicate PNGs. When no interactive Design artifact is available, capture and surface desktop/mobile PNG evidence inline.',
|
|
51
|
-
'BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done. Captures are verification evidence by default; set card=true only for an intentional user-facing mockup or Design deliverable, never merely because a turn changed visual code.',
|
|
52
|
-
THINKPOOL_DESIGN_DELIVERY_RULE,
|
|
53
|
-
'VERIFY BEFORE CLAIMING: run or serve what you changed, observe it, and show the evidence in the room — the PNG, passing test output, or real response. If something could not be verified, say exactly what remains unverified.',
|
|
54
|
-
])
|
|
55
|
-
|
|
56
|
-
// Compact enough to repeat adjacent to every normal user turn. The full remote
|
|
57
|
-
// delivery contract is still installed at session boot where the runtime offers
|
|
58
|
-
// a durable system channel; this reminder keeps the high-drift decisions salient
|
|
59
|
-
// after long tool runs, resumes, and compaction without duplicating the manual.
|
|
60
|
-
export const THINKPOOL_RUNTIME_TURN_REMINDER = [
|
|
61
|
-
THINKPOOL_AGENT_CONTRACT,
|
|
62
|
-
'REMOTE DELIVERY: assume the people are remote unless they explicitly say otherwise. Run host-side work yourself; never hand them a local path, file:// or localhost URL, or ask them to use the host terminal. Surface reachable links and inline evidence in the room.',
|
|
63
|
-
THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE,
|
|
64
|
-
'VISUAL DELIVERY: build first and verify exact desktop and mobile renders. If a source-backed Thinkpool Design card/popup is displayed, do not separately surface its PNGs; if no interactive Design artifact is available, surface the desktop/mobile PNG evidence inline. Stop bridge previews when finished.',
|
|
65
|
-
THINKPOOL_DESIGN_INTERACTION_RULE,
|
|
66
|
-
'VERIFY BEFORE CLAIMING: run or serve what changed and show the real response, passing output, or rendered evidence; state exactly what remains unverified.',
|
|
67
|
-
].join(' ')
|
|
68
|
-
|
|
69
|
-
// Repeating the complete router on every turn made a durable operating contract
|
|
70
|
-
// compete with the user's actual request. The task-scope rule is the exception:
|
|
71
|
-
// repeat it beside every user turn because native history and carried recaps can
|
|
72
|
-
// otherwise resurrect an older assignment. Periodic/recovery turns refresh the
|
|
73
|
-
// complete capability map.
|
|
74
|
-
export const THINKPOOL_FULL_REMINDER_INTERVAL = 5
|
|
75
|
-
|
|
76
|
-
export const THINKPOOL_RUNTIME_SALIENCE_REMINDER = [
|
|
77
|
-
THINKPOOL_TURN_SCOPE_RULE,
|
|
78
|
-
'THINKPOOL: prefer relevant exposed room tools unless the user explicitly opts out; obey the durable terminal role and consent rules. Assume the people are remote, do host work yourself, and deliver reachable verified results.',
|
|
79
|
-
THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE,
|
|
80
|
-
].join(' ')
|
|
81
|
-
|
|
82
|
-
const ROUTE_TRIGGERS = Object.freeze(Object.fromEntries(
|
|
83
|
-
THINKPOOL_CAPABILITY_ROUTES.map((route) => [route.id, new RegExp(route.trigger, 'i')]),
|
|
84
|
-
))
|
|
85
|
-
|
|
86
|
-
const DESIGN_ROUTE_REMINDER = thinkPoolCapabilityContract('design-workspace').turnReminder
|
|
87
|
-
|
|
88
|
-
export function usesFullThinkPoolReminder({ promptIndex = 0, forceFull = false } = {}) {
|
|
89
|
-
const index = Math.max(0, Number.isFinite(Number(promptIndex)) ? Math.trunc(Number(promptIndex)) : 0)
|
|
90
|
-
return !!forceFull || index % THINKPOOL_FULL_REMINDER_INTERVAL === 0
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function buildThinkPoolTurnGuidance({ text = '', promptIndex = 0, forceFull = false } = {}) {
|
|
94
|
-
if (usesFullThinkPoolReminder({ promptIndex, forceFull })) return THINKPOOL_RUNTIME_TURN_REMINDER
|
|
95
|
-
const body = String(text || '')
|
|
96
|
-
const routed = THINKPOOL_CAPABILITY_ROUTES
|
|
97
|
-
.filter((route) => ROUTE_TRIGGERS[route.id]?.test(body))
|
|
98
|
-
.map((route) => `${route.id}: ${route.rule}`)
|
|
99
|
-
if (ROUTE_TRIGGERS['work-routing'].test(body)) routed.push(THINKPOOL_CASCADE_RULE)
|
|
100
|
-
if (ROUTE_TRIGGERS['visual-proof'].test(body)) routed.push(DESIGN_ROUTE_REMINDER)
|
|
101
|
-
return [THINKPOOL_RUNTIME_SALIENCE_REMINDER, ...routed].join(' ')
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function roomContextFingerprint(value) {
|
|
105
|
-
return String(value || '')
|
|
106
|
-
// Relative ages change while the underlying lane state does not. Ignore
|
|
107
|
-
// that clock churn when deciding whether a room snapshot is new information.
|
|
108
|
-
.replace(/ · (?:\d+[smhd] ago|no activity)(?=\s+—)/g, ' · age')
|
|
109
|
-
.trim()
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function createRoomContextSelector(roomContext) {
|
|
113
|
-
let previousFingerprint = null
|
|
114
|
-
return ({ force = false } = {}) => {
|
|
115
|
-
let context = null
|
|
116
|
-
try { context = typeof roomContext === 'function' ? roomContext() : roomContext } catch { context = null }
|
|
117
|
-
const value = formatRoomContextProjection(context)
|
|
118
|
-
if (!value) return ''
|
|
119
|
-
const fingerprint = roomContextFingerprint(value)
|
|
120
|
-
const changed = fingerprint !== previousFingerprint
|
|
121
|
-
previousFingerprint = fingerprint
|
|
122
|
-
return force || changed ? value : ''
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// A caller can inspect the deterministic selection without retaining its raw
|
|
127
|
-
// text. The manifest contains only allow-listed source kinds and safe refs.
|
|
128
|
-
export function buildThinkPoolContextManifest({ currentUserTurn = '', roomNow = '', flowSliceDigest = null, approvedHumanResponse = null } = {}) {
|
|
129
|
-
const roomProjection = formatRoomContextProjection(roomNow)
|
|
130
|
-
return buildContextManifest({
|
|
131
|
-
promptBundle: THINKPOOL_PROMPT_BUNDLE,
|
|
132
|
-
sources: [
|
|
133
|
-
{ kind: 'current_user_turn', value: currentUserTurn },
|
|
134
|
-
{ kind: 'room_now_delta', value: roomProjection },
|
|
135
|
-
...(flowSliceDigest ? [{ kind: 'flow_slice_digest', value: flowSliceDigest, ref: { type: 'flow_digest', id: 'local' } }] : []),
|
|
136
|
-
...(approvedHumanResponse ? [{ kind: 'approved_human_response', value: approvedHumanResponse, ref: { type: 'control_item', id: 'approved' } }] : []),
|
|
137
|
-
],
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// One authoritative terminal-identity preamble for every structured runtime.
|
|
142
|
-
// The bridge derives this from durable structural metadata — never from the text
|
|
143
|
-
// of the task handed to the model. That distinction matters because a spawned
|
|
144
|
-
// lane can be asked to "act as conductor", but it is still a sub-terminal owned
|
|
145
|
-
// by its parent; conversely, a human-opened terminal is top-level even when its
|
|
146
|
-
// first task is tiny. Keep capability wording subordinate to structural identity.
|
|
147
|
-
export function buildTerminalRolePrompt({ spawnedBy = null, spawnDepth = 0, cascadeRole = null, flowRole = null, sideParent = null } = {}) {
|
|
148
|
-
const depth = Math.max(0, Number.isFinite(Number(spawnDepth)) ? Math.trunc(Number(spawnDepth)) : 0)
|
|
149
|
-
const parentRef = String(sideParent || spawnedBy || '').slice(0, 8)
|
|
150
|
-
|
|
151
|
-
if (flowRole === 'conductor') {
|
|
152
|
-
return 'TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW CONDUCTOR. You are not a human-opened top-level terminal and not a worker sub-terminal. Your role is decomposition and Flow coordination only; the Flow runtime dispatches its managed lanes. Do not use ordinary spawn_terminal delegation or claim to be an independent room terminal. Follow the stricter Flow conductor role prompt below.'
|
|
153
|
-
}
|
|
154
|
-
if (flowRole === 'builder' || flowRole === 'reviewer') {
|
|
155
|
-
return `TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW ${flowRole.toUpperCase()} LANE. You are a leaf worker/reviewer, not a top-level terminal and not a conductor. Complete only the assigned Flow slice, do not spawn terminals, and finish through the Flow completion contract in your role prompt.`
|
|
156
|
-
}
|
|
157
|
-
if (sideParent) {
|
|
158
|
-
return `TERMINAL ROLE (authoritative): You are a SIDE SUB-TERMINAL attached to main terminal ${parentRef}. You are not an independent top-level terminal and not a Cascade conductor. Complete the bounded side task directly, do not fan out more terminals, and return a compact handoff to main.`
|
|
159
|
-
}
|
|
160
|
-
if (cascadeRole === 'conductor') {
|
|
161
|
-
if (!spawnedBy && depth === 0) {
|
|
162
|
-
return 'TERMINAL ROLE (authoritative): You are an independent MAIN CASCADE CONDUCTOR TERMINAL opened through the room\'s top-level terminal lifecycle. You are not an Ensemble child, spawned sub-terminal, Side lane, or Flow lane. Keep decomposition and integration here. Open only worker SUB-TERMINALS with spawn_terminal, collect and verify their results, and close them. Never call open_main_terminal yourself or delegate conductor responsibility to a worker.'
|
|
163
|
-
}
|
|
164
|
-
return `TERMINAL ROLE (authoritative): You are a MISCLASSIFIED ENSEMBLE SUB-TERMINAL at spawn depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. A spawned lane can never be a Cascade conductor. Work directly as a worker, do not spawn terminals, and report that the parent must use open_main_terminal for a separate conductor.`
|
|
165
|
-
}
|
|
166
|
-
if (!spawnedBy && depth === 0) {
|
|
167
|
-
return 'TERMINAL ROLE (authoritative): You are an independent TOP-LEVEL ThinkPool terminal opened directly in the room. You are not a spawned sub-terminal or worker lane. You may conduct the current task here and open worker SUB-TERMINALS with spawn_terminal. 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.'
|
|
168
|
-
}
|
|
169
|
-
if (depth >= 2) {
|
|
170
|
-
return `TERMINAL ROLE (authoritative): You are a LEAF SUB-TERMINAL at spawn depth ${depth}, owned by parent terminal ${parentRef}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`
|
|
171
|
-
}
|
|
172
|
-
if (cascadeRole === 'worker') {
|
|
173
|
-
return `TERMINAL ROLE (authoritative): You are a WORKER SUB-TERMINAL at spawn depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`
|
|
174
|
-
}
|
|
175
|
-
return `TERMINAL ROLE (authoritative): You are a SPAWNED WORKER SUB-TERMINAL at depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. You are not an independent top-level terminal or a Cascade conductor. Complete the delegated task directly, do not spawn another terminal, and hand evidence/results back to your parent. If the task asks you to conduct, report that the parent must use open_main_terminal instead.`
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
|
|
179
|
-
'ENVIRONMENT (authoritative): You are Codex running inside a ThinkPool Code room, driven live through the thinkpool-pair bridge by a user and possibly a partner. This is a shared remote workspace, not an ordinary local terminal session; act accordingly.',
|
|
180
|
-
'MODEL TIERING FOR TERMINALS: When calling open_main_terminal for a genuinely complex Cascade conductor, gpt-5.6-sol may be appropriate. When calling spawn_terminal for worker slices, explicitly choose gpt-5.6-luna or gpt-5.4-mini for mechanical/scaffold/search/formatting work, gpt-5.6-terra for ordinary feature/fix work, and a balanced tier for adversarial review. Never select Sol for routine workers. If a named model is unavailable, use the nearest advertised lower tier or omit model and allow runtime fallback.',
|
|
181
|
-
...THINKPOOL_REMOTE_DELIVERY_RULES,
|
|
182
|
-
].join(' ')
|
|
183
|
-
|
|
184
|
-
// Every top-level main can open every supported runtime. Hermes model selection
|
|
185
|
-
// is acknowledged by the child ACP session before inference, so it is safe for a
|
|
186
|
-
// Claude or Codex parent to request a visible Hermes lane.
|
|
187
|
-
export const HERMES_VISIBLE_WORKER_FALLBACK_RULE = 'UNIVERSAL OPENER ROUTING: a top-level terminal of any runtime may use spawn_terminal or open_main_terminal to open Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. For Hermes, omit model to use the isolated profile default or pass an ACP ID such as model="nous:z-ai/glm-5.2"; common GLM shorthand is normalized and the child acknowledges the selected model before inference. If a native Claude Code worker reports an account/provider authentication or subscription-access failure, treat the entire native Claude provider as unavailable for the rest of the task—do not retry Sonnet, Opus, or Haiku aliases on it. For a manual Claude-family review fallback, use runtime="hermes", the exact Nous Claude model suggested by read_terminal, mode="default", and sliceType="review". This fallback is permission-gated, not structurally read-only: instruct it not to edit, stop/deny any write request, and never call it Flow/reviewer safety. Never use hidden Hermes delegate_task.'
|
|
1
|
+
import{THINKPOOL_CAPABILITY_ROUTES as e,THINKPOOL_PROMPT_BUNDLE as t,thinkPoolCapabilityContract as o}from"./thinkpool-prompt-contracts.mjs";import{buildContextManifest as r,formatRoomContextProjection as a}from"./context-contract.mjs";export{e as THINKPOOL_CAPABILITY_ROUTES,t as THINKPOOL_PROMPT_BUNDLE};export const THINKPOOL_ROUTED_TOOLS=Object.freeze([...new Set(e.flatMap(e=>e.tools))]);export function renderThinkPoolCapabilityRoutes(t=e){return t.map(e=>`${e.id}: ${e.rule}`).join(" ")}export const THINKPOOL_RUNTIME_AUTHORITY_RULE=o("runtime-authority").globalPrompt;export const THINKPOOL_TURN_SCOPE_RULE=o("turn-scope-authority").globalPrompt;export const THINKPOOL_CASCADE_RULE=o("work-routing").expandedPrompt;export const THINKPOOL_AGENT_CONTRACT=[THINKPOOL_TURN_SCOPE_RULE,"THINKPOOL-FIRST OPERATING CONTRACT (authoritative): ThinkPool room capabilities are your normal operating surface, not optional enrichment. Before acting on every request, infer which exposed ThinkPool capabilities materially improve room visibility, coordination, delivery, or verification and use them without waiting for the people to know a tool name, magic word, or workflow.",THINKPOOL_RUNTIME_AUTHORITY_RULE,`DEFAULT ROUTING: ${renderThinkPoolCapabilityRoutes()}`,THINKPOOL_CASCADE_RULE,"DEFAULT DOES NOT MEAN GRATUITOUS: use only capabilities exposed to your current role and only when relevant. Honor explicit steering such as single-lane, no research, do not contact another room, or do not use a named ThinkPool feature. Metered and side-effecting capabilities still obey their stated offer, approval, and consent contracts."].join(" ");export const THINKPOOL_DESIGN_INTERACTION_RULE=o("design-workspace").interactionPrompt;export const THINKPOOL_DESIGN_DELIVERY_RULE=`THINKPOOL DESIGN (mandatory for authored HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a source-backed Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. The interactive Design card/popup is the visible result: the renders remain required verification inputs, but when that card is displayed do not also surface duplicate inline PNGs. Surface desktop/mobile PNGs only when no interactive source-backed Design artifact is available. ${THINKPOOL_DESIGN_INTERACTION_RULE} When the requested Design surface is an existing product page or route, first build and capture the actual route at both viewports, then read its current source, styles, copy, fonts, and assets. Derive the editable Design artifact from that evidence and compare both artifact captures against the real route before delivery. Preserve the real page faithfully except for explicitly proposed edits—never hand-recreate it from memory, simplify it, replace it with generic mockup content, or label an approximation as the product. If a faithful editable artifact cannot be produced, surface the actual route captures and say that Design editing is unavailable for that surface. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.`;export const THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE="REMOTE ARTIFACT ACCESS (authoritative): ThinkPool room members may be reviewing from a phone and have only their existing ThinkPool session. Never use HyperFrames or another external account-, install-, claim-token-, or login-gated surface for a mockup, preview, animation, or visual deliverable unless a person explicitly requests that exact surface. Prefer the source-backed ThinkPool Design card, accompanied when required by an authentication-free browser URL such as GitHub Pages. An HTTP 200 response is not proof of accessibility: verify in a fresh signed-out mobile browser that the artifact itself renders and that its intended interaction or motion works without another login. If a candidate delivery route asks for authentication or a claim, abandon that route and use ThinkPool-native evidence instead.";export const THINKPOOL_REMOTE_DELIVERY_RULES=Object.freeze(['REMOTE USER (authoritative default for ThinkPool Code rooms): unless a person explicitly says they are at the host machine, assume everyone driving this room is remote — possibly on a phone — with no terminal and no access to the host filesystem. Never ask them to run a local command, open a local file, or "go check" something on the machine. Anything host-side, you run yourself and show the result in the room. Only suggest actions they can actually do from the room UI or a browser.',"LINKS & ARTIFACTS: a local filesystem path, file:// URL, localhost/127.0.0.1 address, or host-only preview is useless to a remote room. Every link you surface must be reachable by the people in the room.",THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE,"Whenever you produce HTML or shareable markup — a demo, mockup, preview, report, or page — publish it to a browser-renderable GitHub-shareable URL, normally GitHub Pages (or an equivalent URL that actually renders; raw.githubusercontent.com serves HTML as plain text). Give the room that shareable URL in addition to the Thinkpool Design card, never instead of it and never only as a local path. If you cannot publish it, say so instead of falling back to a host-only path.","SHOW VISUAL WORK: whenever you build, change, or fix anything visual, show the result in the room. A source-backed Thinkpool Design card/popup is already the interactive visible result, so do not additionally surface duplicate PNGs. When no interactive Design artifact is available, capture and surface desktop/mobile PNG evidence inline.","BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done. Captures are verification evidence by default; set card=true only for an intentional user-facing mockup or Design deliverable, never merely because a turn changed visual code.",THINKPOOL_DESIGN_DELIVERY_RULE,"VERIFY BEFORE CLAIMING: run or serve what you changed, observe it, and show the evidence in the room — the PNG, passing test output, or real response. If something could not be verified, say exactly what remains unverified."]);export const THINKPOOL_RUNTIME_TURN_REMINDER=[THINKPOOL_AGENT_CONTRACT,"REMOTE DELIVERY: assume the people are remote unless they explicitly say otherwise. Run host-side work yourself; never hand them a local path, file:// or localhost URL, or ask them to use the host terminal. Surface reachable links and inline evidence in the room.",THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE,"VISUAL DELIVERY: build first and verify exact desktop and mobile renders. If a source-backed Thinkpool Design card/popup is displayed, do not separately surface its PNGs; if no interactive Design artifact is available, surface the desktop/mobile PNG evidence inline. Stop bridge previews when finished.",THINKPOOL_DESIGN_INTERACTION_RULE,"VERIFY BEFORE CLAIMING: run or serve what changed and show the real response, passing output, or rendered evidence; state exactly what remains unverified."].join(" ");export const THINKPOOL_FULL_REMINDER_INTERVAL=5;export const THINKPOOL_RUNTIME_SALIENCE_REMINDER=[THINKPOOL_TURN_SCOPE_RULE,"THINKPOOL: prefer relevant exposed room tools unless the user explicitly opts out; obey the durable terminal role and consent rules. Assume the people are remote, do host work yourself, and deliver reachable verified results.",THINKPOOL_REMOTE_ARTIFACT_ACCESS_RULE].join(" ");const n=Object.freeze(Object.fromEntries(e.map(e=>[e.id,new RegExp(e.trigger,"i")]))),i=o("design-workspace").turnReminder;export function usesFullThinkPoolReminder({promptIndex:e=0,forceFull:t=!1}={}){const o=Math.max(0,Number.isFinite(Number(e))?Math.trunc(Number(e)):0);return!!t||o%5==0}export function buildThinkPoolTurnGuidance({text:t="",promptIndex:o=0,forceFull:r=!1}={}){if(usesFullThinkPoolReminder({promptIndex:o,forceFull:r}))return THINKPOOL_RUNTIME_TURN_REMINDER;const a=String(t||""),s=e.filter(e=>n[e.id]?.test(a)).map(e=>`${e.id}: ${e.rule}`);return n["work-routing"].test(a)&&s.push(THINKPOOL_CASCADE_RULE),n["visual-proof"].test(a)&&s.push(i),[THINKPOOL_RUNTIME_SALIENCE_REMINDER,...s].join(" ")}export function roomContextFingerprint(e){return String(e||"").replace(/ · (?:\d+[smhd] ago|no activity)(?=\s+—)/g," · age").trim()}export function createRoomContextSelector(e){let t=null;return({force:o=!1}={})=>{let r=null;try{r="function"==typeof e?e():e}catch{r=null}const n=a(r);if(!n)return"";const i=roomContextFingerprint(n),s=i!==t;return t=i,o||s?n:""}}export function buildThinkPoolContextManifest({currentUserTurn:e="",roomNow:o="",flowSliceDigest:n=null,approvedHumanResponse:i=null}={}){const s=a(o);return r({promptBundle:t,sources:[{kind:"current_user_turn",value:e},{kind:"room_now_delta",value:s},...n?[{kind:"flow_slice_digest",value:n,ref:{type:"flow_digest",id:"local"}}]:[],...i?[{kind:"approved_human_response",value:i,ref:{type:"control_item",id:"approved"}}]:[]]})}export function buildTerminalRolePrompt({spawnedBy:e=null,spawnDepth:t=0,cascadeRole:o=null,flowRole:r=null,sideParent:a=null}={}){const n=Math.max(0,Number.isFinite(Number(t))?Math.trunc(Number(t)):0),i=String(a||e||"").slice(0,8);return"conductor"===r?"TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW CONDUCTOR. You are not a human-opened top-level terminal and not a worker sub-terminal. Your role is decomposition and Flow coordination only; the Flow runtime dispatches its managed lanes. Do not use ordinary spawn_terminal delegation or claim to be an independent room terminal. Follow the stricter Flow conductor role prompt below.":"builder"===r||"reviewer"===r?`TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW ${r.toUpperCase()} LANE. You are a leaf worker/reviewer, not a top-level terminal and not a conductor. Complete only the assigned Flow slice, do not spawn terminals, and finish through the Flow completion contract in your role prompt.`:a?`TERMINAL ROLE (authoritative): You are a SIDE SUB-TERMINAL attached to main terminal ${i}. You are not an independent top-level terminal and not a Cascade conductor. Complete the bounded side task directly, do not fan out more terminals, and return a compact handoff to main.`:"conductor"===o?e||0!==n?`TERMINAL ROLE (authoritative): You are a MISCLASSIFIED ENSEMBLE SUB-TERMINAL at spawn depth ${Math.max(1,n)}, owned by parent terminal ${i}. A spawned lane can never be a Cascade conductor. Work directly as a worker, do not spawn terminals, and report that the parent must use open_main_terminal for a separate conductor.`:"TERMINAL ROLE (authoritative): You are an independent MAIN CASCADE CONDUCTOR TERMINAL opened through the room's top-level terminal lifecycle. You are not an Ensemble child, spawned sub-terminal, Side lane, or Flow lane. Keep decomposition and integration here. Open only worker SUB-TERMINALS with spawn_terminal, collect and verify their results, and close them. Never call open_main_terminal yourself or delegate conductor responsibility to a worker.":e||0!==n?n>=2?`TERMINAL ROLE (authoritative): You are a LEAF SUB-TERMINAL at spawn depth ${n}, owned by parent terminal ${i}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`:"worker"===o?`TERMINAL ROLE (authoritative): You are a WORKER SUB-TERMINAL at spawn depth ${Math.max(1,n)}, owned by parent terminal ${i}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`:`TERMINAL ROLE (authoritative): You are a SPAWNED WORKER SUB-TERMINAL at depth ${Math.max(1,n)}, owned by parent terminal ${i}. You are not an independent top-level terminal or a Cascade conductor. Complete the delegated task directly, do not spawn another terminal, and hand evidence/results back to your parent. If the task asks you to conduct, report that the parent must use open_main_terminal instead.`:"TERMINAL ROLE (authoritative): You are an independent TOP-LEVEL ThinkPool terminal opened directly in the room. You are not a spawned sub-terminal or worker lane. You may conduct the current task here and open worker SUB-TERMINALS with spawn_terminal. 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."}export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE=["ENVIRONMENT (authoritative): You are Codex running inside a ThinkPool Code room, driven live through the thinkpool-pair bridge by a user and possibly a partner. This is a shared remote workspace, not an ordinary local terminal session; act accordingly.","MODEL TIERING FOR TERMINALS: When calling open_main_terminal for a genuinely complex Cascade conductor, gpt-5.6-sol may be appropriate. When calling spawn_terminal for worker slices, explicitly choose gpt-5.6-luna or gpt-5.4-mini for mechanical/scaffold/search/formatting work, gpt-5.6-terra for ordinary feature/fix work, and a balanced tier for adversarial review. Never select Sol for routine workers. If a named model is unavailable, use the nearest advertised lower tier or omit model and allow runtime fallback.",...THINKPOOL_REMOTE_DELIVERY_RULES].join(" ");export const HERMES_VISIBLE_WORKER_FALLBACK_RULE='UNIVERSAL OPENER ROUTING: a top-level terminal of any runtime may use spawn_terminal or open_main_terminal to open Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. For Hermes, omit model to use the isolated profile default or pass an ACP ID such as model="nous:z-ai/glm-5.2"; common GLM shorthand is normalized and the child acknowledges the selected model before inference. If a native Claude Code worker reports an account/provider authentication or subscription-access failure, treat the entire native Claude provider as unavailable for the rest of the task—do not retry Sonnet, Opus, or Haiku aliases on it. For a manual Claude-family review fallback, use runtime="hermes", the exact Nous Claude model suggested by read_terminal, mode="default", and sliceType="review". This fallback is permission-gated, not structurally read-only: instruct it not to edit, stop/deny any write request, and never call it Flow/reviewer safety. Never use hidden Hermes delegate_task.';
|
package/transcript-sanitize.mjs
CHANGED
|
@@ -1,332 +1 @@
|
|
|
1
|
-
|
|
2
|
-
transcript-sanitize.mjs — heal cross-provider tool blocks in a
|
|
3
|
-
Claude Code transcript before the SDK replays it.
|
|
4
|
-
|
|
5
|
-
THE BUG IT PREVENTS (failure: feedback_bridge_provider_swap_srvtoolu):
|
|
6
|
-
When a room runs on a non-Anthropic provider (Z.ai GLM, any
|
|
7
|
-
OpenAI-compatible endpoint), Claude Code persists that provider's
|
|
8
|
-
custom server-side tools into the transcript as `server_tool_use`
|
|
9
|
-
blocks — but with that provider's tool ids/names (e.g. an OpenAI
|
|
10
|
-
`call_<hex>` id, a name like `webReader` / `analyze_image`). Swap the
|
|
11
|
-
provider back to `anthropic` and the next turn replays the whole
|
|
12
|
-
transcript; Anthropic STRICTLY validates server_tool_use blocks and
|
|
13
|
-
400s on the first illegal one:
|
|
14
|
-
messages.N.content.M.server_tool_use.id: String should match '^srvtoolu_…'
|
|
15
|
-
messages.N.content.M.server_tool_use.name: Input should be 'web_search', …
|
|
16
|
-
The room is then wedged — every send re-hits message N and fails.
|
|
17
|
-
|
|
18
|
-
THE FIX: reclassify each illegal block from `server_tool_use` to a
|
|
19
|
-
plain `tool_use`. Anthropic only enforces the id-pattern and the
|
|
20
|
-
name-enum on SERVER tools; a regular tool_use replays with ANY id and
|
|
21
|
-
ANY name (the transcript's own `call_*` client tools — Bash, Edit,
|
|
22
|
-
WebSearch — prove this; they never error). The block keeps its id, so
|
|
23
|
-
it stays paired with its existing tool_result. This clears BOTH the
|
|
24
|
-
id and the name error in one move and is the universally-valid form,
|
|
25
|
-
so it's safe to run regardless of which provider wrote the history.
|
|
26
|
-
|
|
27
|
-
NB: do NOT "fix" this by renaming the id to `srvtoolu_…` — that leaves
|
|
28
|
-
the bogus tool NAME, so the name-enum error survives and the room
|
|
29
|
-
stays stuck. (That half-fix is the documented trap.)
|
|
30
|
-
|
|
31
|
-
Idempotent, crash-safe (atomic rename), and a cheap string pre-check
|
|
32
|
-
means it's a no-op on clean transcripts. Never throws into the caller.
|
|
33
|
-
───────────────────────────────────────────────────────────── */
|
|
34
|
-
|
|
35
|
-
import fs from 'node:fs'
|
|
36
|
-
import os from 'node:os'
|
|
37
|
-
import path from 'node:path'
|
|
38
|
-
|
|
39
|
-
// Anthropic's real server-tool names. A `server_tool_use` block whose name
|
|
40
|
-
// is NOT in this set came from another provider and must be reclassified.
|
|
41
|
-
const ANTHROPIC_SERVER_TOOLS = new Set([
|
|
42
|
-
'web_search',
|
|
43
|
-
'web_fetch',
|
|
44
|
-
'code_execution',
|
|
45
|
-
'bash_code_execution',
|
|
46
|
-
'text_editor_code_execution',
|
|
47
|
-
'tool_search_tool_regex',
|
|
48
|
-
'tool_search_tool_bm25',
|
|
49
|
-
])
|
|
50
|
-
|
|
51
|
-
const STU_MARKER = '"server_tool_use"'
|
|
52
|
-
const TR_MARKER = '"tool_result"'
|
|
53
|
-
const TU_MARKER = '"tool_use"' // matches "type":"tool_use" but NOT "server_tool_use" or "tool_use_id"
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Resolve a session's transcript path the same way Claude Code names it:
|
|
57
|
-
* ~/.claude/projects/<cwd with [/\.:] → '-'>/<sessionId>.jsonl
|
|
58
|
-
*/
|
|
59
|
-
export function transcriptPath(cwd, sessionId) {
|
|
60
|
-
const slug = path.resolve(cwd || process.cwd()).replace(/[/\\.:]/g, '-')
|
|
61
|
-
return path.join(os.homedir(), '.claude', 'projects', slug, `${sessionId}.jsonl`)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Flip illegal server_tool_use blocks in one content array. Returns count flipped.
|
|
65
|
-
function fixContent(content) {
|
|
66
|
-
if (!Array.isArray(content)) return 0
|
|
67
|
-
let n = 0
|
|
68
|
-
for (const b of content) {
|
|
69
|
-
if (b && b.type === 'server_tool_use' && !ANTHROPIC_SERVER_TOOLS.has(b.name)) {
|
|
70
|
-
b.type = 'tool_use'
|
|
71
|
-
n++
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return n
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/* ─────────────────────────────────────────────────────────────
|
|
78
|
-
SECOND HEAL — interrupted-turn corruption (failure 2026-06-26):
|
|
79
|
-
a `tool_result` block stranded inside an ASSISTANT message.
|
|
80
|
-
|
|
81
|
-
When a turn is aborted/interrupted mid-tool-call, Claude Code can
|
|
82
|
-
persist a single assistant message holding BOTH the tool_use AND its
|
|
83
|
-
tool_result (plus stray thinking/text). Anthropic strictly 400s:
|
|
84
|
-
messages.N: tool_result blocks can only be in user messages
|
|
85
|
-
and the room wedges — every send re-hits message N and fails (exactly
|
|
86
|
-
what bricked the /code GLM session, transcript line 1196 / messages.165).
|
|
87
|
-
|
|
88
|
-
THE FIX: in an assistant content array, drop every stray tool_result
|
|
89
|
-
AND the tool_use block(s) it pairs with (same id) — removing the pair
|
|
90
|
-
together so no orphaned tool_use is left behind (an orphan would 400
|
|
91
|
-
differently: "tool_use ids were found without tool_result blocks").
|
|
92
|
-
The aborted call is dead history; dropping it lets the session resume.
|
|
93
|
-
If the message would be left empty, leave a placeholder text block
|
|
94
|
-
(Anthropic rejects a content-less assistant message). In-place, no row
|
|
95
|
-
split — so Claude Code's own uuid/parentUuid chain stays intact.
|
|
96
|
-
Returns the new content array + count removed. ───────────────────── */
|
|
97
|
-
function fixToolResultInAssistant(content) {
|
|
98
|
-
if (!Array.isArray(content)) return { removed: 0, content }
|
|
99
|
-
const resultIds = new Set()
|
|
100
|
-
for (const b of content) if (b && b.type === 'tool_result') resultIds.add(b.tool_use_id)
|
|
101
|
-
if (!resultIds.size) return { removed: 0, content } // no stray result here — nothing to do
|
|
102
|
-
const before = content.length
|
|
103
|
-
let next = content.filter((b) =>
|
|
104
|
-
b && b.type !== 'tool_result' && !(b.type === 'tool_use' && resultIds.has(b.id)))
|
|
105
|
-
if (next.length === 0) next = [{ type: 'text', text: '[interrupted turn removed by transcript-sanitize]' }]
|
|
106
|
-
return { removed: before - next.length, content: next }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/* ─────────────────────────────────────────────────────────────
|
|
110
|
-
THIRD HEAL — dangling tool_use (S4 clean-redispatch, 2026-06-30):
|
|
111
|
-
a lane force-killed mid-tool-call leaves an assistant message with a
|
|
112
|
-
tool_use block that has NO matching tool_result anywhere in the
|
|
113
|
-
transcript. On the next SDK replay Anthropic 400s:
|
|
114
|
-
"tool_use ids were found without tool_result blocks"
|
|
115
|
-
and the lane cannot resume.
|
|
116
|
-
|
|
117
|
-
THE FIX: cross-row scan — collect every tool_use id and every
|
|
118
|
-
tool_result tool_use_id across the whole transcript; any tool_use id
|
|
119
|
-
with no matching tool_result is "dangling". For each assistant message
|
|
120
|
-
that holds dangling calls, inject a synthetic user message immediately
|
|
121
|
-
after it carrying one tool_result per dangling id:
|
|
122
|
-
{ type:'tool_result', tool_use_id:<id>, content:'[interrupted]', is_error:true }
|
|
123
|
-
This is the minimal valid placeholder that unblocks the next turn.
|
|
124
|
-
|
|
125
|
-
Idempotent: a tool_use already paired with a tool_result is untouched.
|
|
126
|
-
A second pass over the same (now-healed) transcript is a no-op because
|
|
127
|
-
the synthetic results are part of the file and pair the calls.
|
|
128
|
-
───────────────────────────────────────────────────────────── */
|
|
129
|
-
|
|
130
|
-
// Return all tool_use ids from a parsed row (checks .message.content AND top-level .content).
|
|
131
|
-
function getToolUseIds(obj) {
|
|
132
|
-
const ids = []
|
|
133
|
-
const scan = (content) => {
|
|
134
|
-
if (!Array.isArray(content)) return
|
|
135
|
-
for (const b of content) { if (b && b.type === 'tool_use' && b.id) ids.push(b.id) }
|
|
136
|
-
}
|
|
137
|
-
if (obj.message) scan(obj.message.content)
|
|
138
|
-
scan(obj.content)
|
|
139
|
-
return ids
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Collect all tool_result tool_use_ids from a parsed row into a Set.
|
|
143
|
-
function collectToolResultIds(obj, into) {
|
|
144
|
-
const scan = (content) => {
|
|
145
|
-
if (!Array.isArray(content)) return
|
|
146
|
-
for (const b of content) { if (b && b.type === 'tool_result' && b.tool_use_id) into.add(b.tool_use_id) }
|
|
147
|
-
}
|
|
148
|
-
if (obj.message) scan(obj.message.content)
|
|
149
|
-
scan(obj.content)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Return the role of a parsed row ('assistant' | 'user' | null).
|
|
153
|
-
function getRole(obj) {
|
|
154
|
-
if (!obj) return null
|
|
155
|
-
if (obj.message && obj.message.role) return obj.message.role
|
|
156
|
-
if (obj.type === 'assistant') return 'assistant'
|
|
157
|
-
if (obj.type === 'user') return 'user'
|
|
158
|
-
return null
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Heal 3: scan parsed objects for dangling tool_use ids; insert synthetic
|
|
163
|
-
* user rows immediately after the assistant rows that own them.
|
|
164
|
-
* @param {string[]} rows — string rows (already updated by Heals 1+2)
|
|
165
|
-
* @param {(object|null)[]} parsed — objects (mutated in-place by Heals 1+2)
|
|
166
|
-
* @returns {{ rows: string[], injected: number }}
|
|
167
|
-
*/
|
|
168
|
-
function fixDanglingToolUse(rows, parsed) {
|
|
169
|
-
// Full-transcript scan: collect every tool_result tool_use_id (= paired ids).
|
|
170
|
-
const pairedIds = new Set()
|
|
171
|
-
for (const obj of parsed) {
|
|
172
|
-
if (obj) collectToolResultIds(obj, pairedIds)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
let injected = 0
|
|
176
|
-
const out = []
|
|
177
|
-
for (let i = 0; i < rows.length; i++) {
|
|
178
|
-
out.push(rows[i])
|
|
179
|
-
const obj = parsed[i]
|
|
180
|
-
if (!obj || getRole(obj) !== 'assistant') continue
|
|
181
|
-
|
|
182
|
-
const dangling = getToolUseIds(obj).filter(id => !pairedIds.has(id))
|
|
183
|
-
if (!dangling.length) continue
|
|
184
|
-
|
|
185
|
-
// Inject synthetic user row with one tool_result per dangling call.
|
|
186
|
-
const synRow = {
|
|
187
|
-
type: 'user',
|
|
188
|
-
message: {
|
|
189
|
-
role: 'user',
|
|
190
|
-
content: dangling.map(id => ({
|
|
191
|
-
type: 'tool_result',
|
|
192
|
-
tool_use_id: id,
|
|
193
|
-
content: '[interrupted]',
|
|
194
|
-
is_error: true,
|
|
195
|
-
})),
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
|
-
out.push(JSON.stringify(synRow))
|
|
199
|
-
// Mark injected ids as paired so a second pass treats them as clean (idempotency).
|
|
200
|
-
for (const id of dangling) pairedIds.add(id)
|
|
201
|
-
injected += dangling.length
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return { rows: out, injected }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Sanitize one transcript file in place. Returns { lines, blocks } counting the
|
|
209
|
-
* transcript lines rewritten and the individual blocks healed/injected. A missing,
|
|
210
|
-
* unreadable, or already-clean file is a silent no-op ({ lines: 0, blocks: 0 }).
|
|
211
|
-
*/
|
|
212
|
-
export function sanitizeTranscript(file) {
|
|
213
|
-
let raw
|
|
214
|
-
try {
|
|
215
|
-
raw = fs.readFileSync(file, 'utf8')
|
|
216
|
-
} catch {
|
|
217
|
-
return { lines: 0, blocks: 0 } // no transcript yet (fresh session) — nothing to do
|
|
218
|
-
}
|
|
219
|
-
const fileHasStu = raw.includes(STU_MARKER)
|
|
220
|
-
const fileHasTr = raw.includes(TR_MARKER)
|
|
221
|
-
const fileHasTu = raw.includes(TU_MARKER)
|
|
222
|
-
if (!fileHasStu && !fileHasTr && !fileHasTu) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
|
|
223
|
-
|
|
224
|
-
const rows = raw.split('\n')
|
|
225
|
-
let lines = 0
|
|
226
|
-
let blocks = 0
|
|
227
|
-
|
|
228
|
-
// Pre-parse ALL rows so Heal 3 can do cross-row analysis.
|
|
229
|
-
// Heals 1+2 mutate these objects in-place, so parsed[i] always reflects the
|
|
230
|
-
// post-heal state when Heal 3 runs. Unparseable lines → null (left byte-identical).
|
|
231
|
-
const parsed = rows.map(row => {
|
|
232
|
-
if (!row) return null
|
|
233
|
-
try { return JSON.parse(row) } catch { return null }
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
for (let i = 0; i < rows.length; i++) {
|
|
237
|
-
const row = rows[i]
|
|
238
|
-
if (!row) continue
|
|
239
|
-
const rowHasStu = row.includes(STU_MARKER)
|
|
240
|
-
const rowHasTr = row.includes(TR_MARKER)
|
|
241
|
-
if (!rowHasStu && !rowHasTr) continue
|
|
242
|
-
const obj = parsed[i]
|
|
243
|
-
if (!obj) continue // unparseable — already null, leave byte-identical
|
|
244
|
-
let n = 0
|
|
245
|
-
// Pass 1 — foreign server_tool_use → tool_use (any message).
|
|
246
|
-
if (rowHasStu) {
|
|
247
|
-
if (obj.message && typeof obj.message === 'object') n += fixContent(obj.message.content)
|
|
248
|
-
n += fixContent(obj.content)
|
|
249
|
-
}
|
|
250
|
-
// Pass 2 — stray tool_result inside an assistant message → drop it + its paired tool_use.
|
|
251
|
-
if (rowHasTr) {
|
|
252
|
-
if (obj.message && obj.message.role === 'assistant') {
|
|
253
|
-
const r = fixToolResultInAssistant(obj.message.content)
|
|
254
|
-
if (r.removed) { obj.message.content = r.content; n += r.removed }
|
|
255
|
-
}
|
|
256
|
-
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
|
|
257
|
-
const r = fixToolResultInAssistant(obj.content)
|
|
258
|
-
if (r.removed) { obj.content = r.content; n += r.removed }
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (n > 0) {
|
|
262
|
-
rows[i] = JSON.stringify(obj)
|
|
263
|
-
lines++
|
|
264
|
-
blocks += n
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Heal 3 — dangling tool_use: cross-row scan + synthetic row insertion.
|
|
269
|
-
// Runs after Heals 1+2 so parsed objects already reflect those fixes.
|
|
270
|
-
//
|
|
271
|
-
// Gate: run whenever the fast-path was NOT taken — i.e., always here.
|
|
272
|
-
// The old `if (fileHasTu)` guard was wrong for a heal that depends on Heal 1's
|
|
273
|
-
// output: Heal 1 can create NEW tool_use blocks by flipping server_tool_use, but
|
|
274
|
-
// the raw `"server_tool_use"` string does NOT contain the `"tool_use"` substring
|
|
275
|
-
// (the `server_` prefix + underscore break the literal match), so fileHasTu=false
|
|
276
|
-
// and Heal 3 was skipped — leaving the now-dangling call unhealed (bug C9).
|
|
277
|
-
// The fast-path early-return above is the real no-op gate for truly-clean files.
|
|
278
|
-
let outRows = rows
|
|
279
|
-
{
|
|
280
|
-
const h3 = fixDanglingToolUse(rows, parsed)
|
|
281
|
-
if (h3.injected) {
|
|
282
|
-
outRows = h3.rows
|
|
283
|
-
blocks += h3.injected
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!blocks) return { lines: 0, blocks: 0 } // nothing was changed by any heal
|
|
288
|
-
|
|
289
|
-
// Atomic write — a crash mid-write must never leave a truncated transcript.
|
|
290
|
-
const tmp = `${file}.tp-sanitize.tmp`
|
|
291
|
-
fs.writeFileSync(tmp, outRows.join('\n'))
|
|
292
|
-
fs.renameSync(tmp, file)
|
|
293
|
-
return { lines, blocks }
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Resolve cwd+sessionId to a path and sanitize. Wrapped so a sanitize failure
|
|
298
|
-
* can never block session start — worst case the room behaves as it did before.
|
|
299
|
-
* Returns the same shape as sanitizeTranscript.
|
|
300
|
-
*/
|
|
301
|
-
export function sanitizeSession(cwd, sessionId) {
|
|
302
|
-
if (!sessionId) return { lines: 0, blocks: 0 }
|
|
303
|
-
try {
|
|
304
|
-
return sanitizeTranscript(transcriptPath(cwd, sessionId))
|
|
305
|
-
} catch {
|
|
306
|
-
return { lines: 0, blocks: 0 }
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/* ─────────────────────────────────────────────────────────────
|
|
311
|
-
CLI — manual recovery for a wedged transcript (the auto-heal runs on
|
|
312
|
-
every resume via claude-session.mjs; this is the break-glass tool):
|
|
313
|
-
node bridge/transcript-sanitize.mjs <path-to-transcript.jsonl>
|
|
314
|
-
node bridge/transcript-sanitize.mjs <cwd> <sessionId>
|
|
315
|
-
Prints what it healed. A backup (<file>.bak) is written before any
|
|
316
|
-
change so a bad heal is one `mv` from undone.
|
|
317
|
-
───────────────────────────────────────────────────────────── */
|
|
318
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
319
|
-
const a = process.argv.slice(2)
|
|
320
|
-
if (a.length === 0) {
|
|
321
|
-
process.stderr.write('usage: transcript-sanitize.mjs <transcript.jsonl> | <cwd> <sessionId>\n')
|
|
322
|
-
process.exit(2)
|
|
323
|
-
}
|
|
324
|
-
const file = a.length >= 2 ? transcriptPath(a[0], a[1]) : a[0]
|
|
325
|
-
if (!fs.existsSync(file)) { process.stderr.write(`not found: ${file}\n`); process.exit(1) }
|
|
326
|
-
fs.copyFileSync(file, `${file}.bak`) // break-glass backup
|
|
327
|
-
const r = sanitizeTranscript(file)
|
|
328
|
-
process.stdout.write(r.blocks
|
|
329
|
-
? `healed ${r.blocks} malformed tool block(s) across ${r.lines} line(s) in ${file}\n backup: ${file}.bak\n`
|
|
330
|
-
: `no malformed tool blocks found in ${file} (already clean)\n`)
|
|
331
|
-
process.exit(0)
|
|
332
|
-
}
|
|
1
|
+
import e from"node:fs";import t from"node:os";import n from"node:path";const s=new Set(["web_search","web_fetch","code_execution","bash_code_execution","text_editor_code_execution","tool_search_tool_regex","tool_search_tool_bm25"]),o='"server_tool_use"',r='"tool_result"';export function transcriptPath(e,s){const o=n.resolve(e||process.cwd()).replace(/[/\\.:]/g,"-");return n.join(t.homedir(),".claude","projects",o,`${s}.jsonl`)}function c(e){if(!Array.isArray(e))return 0;let t=0;for(const n of e)n&&"server_tool_use"===n.type&&!s.has(n.name)&&(n.type="tool_use",t++);return t}function i(e){if(!Array.isArray(e))return{removed:0,content:e};const t=new Set;for(const n of e)n&&"tool_result"===n.type&&t.add(n.tool_use_id);if(!t.size)return{removed:0,content:e};const n=e.length;let s=e.filter(e=>e&&"tool_result"!==e.type&&!("tool_use"===e.type&&t.has(e.id)));return 0===s.length&&(s=[{type:"text",text:"[interrupted turn removed by transcript-sanitize]"}]),{removed:n-s.length,content:s}}function l(e){const t=[],n=e=>{if(Array.isArray(e))for(const n of e)n&&"tool_use"===n.type&&n.id&&t.push(n.id)};return e.message&&n(e.message.content),n(e.content),t}function a(e,t){const n=e=>{if(Array.isArray(e))for(const n of e)n&&"tool_result"===n.type&&n.tool_use_id&&t.add(n.tool_use_id)};e.message&&n(e.message.content),n(e.content)}function u(e){return e?e.message&&e.message.role?e.message.role:"assistant"===e.type?"assistant":"user"===e.type?"user":null:null}export function sanitizeTranscript(t){let n;try{n=e.readFileSync(t,"utf8")}catch{return{lines:0,blocks:0}}const s=n.includes(o),f=n.includes(r),p=n.includes('"tool_use"');if(!s&&!f&&!p)return{lines:0,blocks:0};const d=n.split("\n");let m=0,y=0;const _=d.map(e=>{if(!e)return null;try{return JSON.parse(e)}catch{return null}});for(let e=0;e<d.length;e++){const t=d[e];if(!t)continue;const n=t.includes(o),s=t.includes(r);if(!n&&!s)continue;const l=_[e];if(!l)continue;let a=0;if(n&&(l.message&&"object"==typeof l.message&&(a+=c(l.message.content)),a+=c(l.content)),s){if(l.message&&"assistant"===l.message.role){const e=i(l.message.content);e.removed&&(l.message.content=e.content,a+=e.removed)}if("assistant"===l.type&&Array.isArray(l.content)){const e=i(l.content);e.removed&&(l.content=e.content,a+=e.removed)}}a>0&&(d[e]=JSON.stringify(l),m++,y+=a)}let g=d;{const e=function(e,t){const n=new Set;for(const e of t)e&&a(e,n);let s=0;const o=[];for(let r=0;r<e.length;r++){o.push(e[r]);const c=t[r];if(!c||"assistant"!==u(c))continue;const i=l(c).filter(e=>!n.has(e));if(!i.length)continue;const a={type:"user",message:{role:"user",content:i.map(e=>({type:"tool_result",tool_use_id:e,content:"[interrupted]",is_error:!0}))}};o.push(JSON.stringify(a));for(const e of i)n.add(e);s+=i.length}return{rows:o,injected:s}}(d,_);e.injected&&(g=e.rows,y+=e.injected)}if(!y)return{lines:0,blocks:0};const h=`${t}.tp-sanitize.tmp`;return e.writeFileSync(h,g.join("\n")),e.renameSync(h,t),{lines:m,blocks:y}}export function sanitizeSession(e,t){if(!t)return{lines:0,blocks:0};try{return sanitizeTranscript(transcriptPath(e,t))}catch{return{lines:0,blocks:0}}}if(import.meta.url===`file://${process.argv[1]}`){const f=process.argv.slice(2);0===f.length&&(process.stderr.write("usage: transcript-sanitize.mjs <transcript.jsonl> | <cwd> <sessionId>\n"),process.exit(2));const p=f.length>=2?transcriptPath(f[0],f[1]):f[0];e.existsSync(p)||(process.stderr.write(`not found: ${p}\n`),process.exit(1)),e.copyFileSync(p,`${p}.bak`);const d=sanitizeTranscript(p);process.stdout.write(d.blocks?`healed ${d.blocks} malformed tool block(s) across ${d.lines} line(s) in ${p}\n backup: ${p}.bak\n`:`no malformed tool blocks found in ${p} (already clean)\n`),process.exit(0)}
|
package/turn-stall.mjs
CHANGED
|
@@ -1,61 +1 @@
|
|
|
1
|
-
|
|
2
|
-
turn-stall.mjs — the pure decision layer for the structured-turn stall watchdog.
|
|
3
|
-
|
|
4
|
-
A structured turn can stall with the SDK's `for await` blocked INSIDE an internal retry
|
|
5
|
-
— no event, no throw — so claude-session's catch never runs and nothing reaches the room.
|
|
6
|
-
The 2026-07-08 incident: an Anthropic 529-overload window hung a turn for 14 minutes with
|
|
7
|
-
zero output until it was aborted by hand. The watchdog in claude-session.mjs turns that
|
|
8
|
-
silent wedge into a visible status, then an abort + one auto-retry, then a force-stop.
|
|
9
|
-
|
|
10
|
-
These two functions are the DECISION layer (when to act / what to say). They're pure — no
|
|
11
|
-
SDK, no clock, no I/O — so the stateful loop injects state in and they unit-test against a
|
|
12
|
-
fake clock with zero deps (bridge/test-turn-stall-watchdog.mjs). Same shape + spirit as
|
|
13
|
-
account.mjs's claimLoopWedged / withTimeout. Kept in their OWN module (no SDK import) so the
|
|
14
|
-
test needs neither the agent SDK nor auth. The loop owns the flags + the abort/re-push side
|
|
15
|
-
effects; this only says WHAT to do.
|
|
16
|
-
───────────────────────────────────────────────────────────── */
|
|
17
|
-
|
|
18
|
-
// Decide the watchdog action for an ACTIVE turn gone quiet (quietMs = ms since the last
|
|
19
|
-
// emitted SDK event). Idle (no turn) or awaiting a HUMAN decision (permission card / plan
|
|
20
|
-
// gate / AskUserQuestion) is never a wedge — the turn is correctly idle until the person
|
|
21
|
-
// answers. Timeline:
|
|
22
|
-
// quietMs > stallMs (90s) → 'status' — surface a "still retrying (Ns)" banner, ONCE
|
|
23
|
-
// quietMs > forceStopMs (5min) → 'retry' — abort the hung call, re-run the turn ONCE
|
|
24
|
-
// quietMs > forceStopMs again → 'giveup' — the one retry also stalled: force-stop, resend
|
|
25
|
-
//
|
|
26
|
-
// COMPACTION is the third "silence is not a wedge" case, alongside idle + awaitingUser.
|
|
27
|
-
// A `/compact` is ONE model call that summarises the whole transcript and emits NOTHING until
|
|
28
|
-
// it lands: measured at 28.5s of dead air on a 54k-token context (bridge/probe-compact.mjs),
|
|
29
|
-
// and Max's real compactions carried 397k–618k pre-tokens — minutes of silence. The watchdog
|
|
30
|
-
// read that as a wedge, fired 'retry' at 300s, and retryStalledTurn's qAc.abort() KILLED the
|
|
31
|
-
// compaction: `AbortError: Compaction canceled.` landed in the shared transcript, the context
|
|
32
|
-
// was unchanged, and the retry re-pushed `/compact` to be aborted again. (Room 1TV7B59A, lane
|
|
33
|
-
// 9ad2e6bf, seq 1210-1215 on 2026-07-09 20:00 — and the same shape again at seq 2239-2241.)
|
|
34
|
-
// So while compacting: never 'status' (the "still retrying" copy is a lie, and the compact
|
|
35
|
-
// indicator already owns that UI) and never 'retry' (the abort IS the bug). A genuinely hung
|
|
36
|
-
// compaction still recovers at a much longer ceiling — 'giveup' force-stops without aborting.
|
|
37
|
-
export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried, compacting = false, compactForceStopMs = Infinity }) {
|
|
38
|
-
if (!turnActive || awaitingUser > 0) return 'none'
|
|
39
|
-
if (compacting) return quietMs > compactForceStopMs ? 'giveup' : 'none'
|
|
40
|
-
if (quietMs > forceStopMs) return stallRetried ? 'giveup' : 'retry'
|
|
41
|
-
if (!stalledSent && quietMs > stallMs) return 'status'
|
|
42
|
-
return 'none'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Is this turn text a manual compaction request? The SDK recognises `/compact` (optionally
|
|
46
|
-
// with focus instructions) as a slash command when the user message is a single text block —
|
|
47
|
-
// see claude-session's sendTurn. Exported so the watchdog and the re-push guard agree.
|
|
48
|
-
export const isCompactTurn = (text) => /^\s*\/compact\b/.test(String(text || ''))
|
|
49
|
-
|
|
50
|
-
// The room-facing event for a watchdog action (pure → the exact wording is unit-tested).
|
|
51
|
-
// 'status' rides the transient `stalled` chrome event (broadcast, never persisted); 'retry'
|
|
52
|
-
// and 'giveup' ride `error` (recoverable) so the stall is ALWAYS surfaced, never swallowed.
|
|
53
|
-
export function stallEvent(action, quietMs) {
|
|
54
|
-
const s = Math.round(quietMs / 1000)
|
|
55
|
-
switch (action) {
|
|
56
|
-
case 'status': return { kind: 'stalled', sinceMs: quietMs, retrying: true, message: `model call stalled — still retrying (${s}s)` }
|
|
57
|
-
case 'retry': return { kind: 'error', recoverable: true, message: `model call stalled ${s}s with no response (upstream overload) — aborting the hung call and retrying the turn once` }
|
|
58
|
-
case 'giveup': return { kind: 'error', recoverable: true, message: `agent went silent for ${s}s — the retry also stalled; force-stopped the turn, send again to resume` }
|
|
59
|
-
default: return null
|
|
60
|
-
}
|
|
61
|
-
}
|
|
1
|
+
export function stallDecision({turnActive:e,awaitingUser:t,quietMs:r,stallMs:s,forceStopMs:n,stalledSent:a,stallRetried:o,compacting:l=!1,compactForceStopMs:i=1/0}){return!e||t>0?"none":l?r>i?"giveup":"none":r>n?o?"giveup":"retry":!a&&r>s?"status":"none"}export const isCompactTurn=e=>/^\s*\/compact\b/.test(String(e||""));export function stallEvent(e,t){const r=Math.round(t/1e3);switch(e){case"status":return{kind:"stalled",sinceMs:t,retrying:!0,message:`model call stalled — still retrying (${r}s)`};case"retry":return{kind:"error",recoverable:!0,message:`model call stalled ${r}s with no response (upstream overload) — aborting the hung call and retrying the turn once`};case"giveup":return{kind:"error",recoverable:!0,message:`agent went silent for ${r}s — the retry also stalled; force-stopped the turn, send again to resume`};default:return null}}
|