thinkpool-pair 0.7.76 → 0.7.78
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/claude-session.mjs +31 -2
- package/package.json +1 -1
- package/transcript-sanitize.mjs +81 -5
package/claude-session.mjs
CHANGED
|
@@ -177,6 +177,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
177
177
|
// the catch self-heals.
|
|
178
178
|
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
179
179
|
let forceStopped = false
|
|
180
|
+
// Set while an interrupt is settling. q.interrupt() doesn't end the turn cleanly on this
|
|
181
|
+
// SDK — it surfaces a REDUNDANT pair of terminal results (subtype `aborted` AND
|
|
182
|
+
// `error_during_execution`) and re-inits the session. abort() emits the single canonical
|
|
183
|
+
// aborted boundary itself; this flag tells the result handler to swallow the SDK's extra
|
|
184
|
+
// teardown results so the web never sees a second turn-end. Without it, the web suppresses
|
|
185
|
+
// only the first (isAbortEcho) and the second buried the flushed steer — "Stop injects the
|
|
186
|
+
// queued message but nothing happens". Cleared by the first real (non-teardown) result.
|
|
187
|
+
// Verified: bridge/test-steer-after-abort.mjs (Maks 2026-06-26). The teardown results
|
|
188
|
+
// arrive async and can interleave with the NEXT turn (a late error_during_execution can
|
|
189
|
+
// land after the steer's own output), so the window is bounded by a timer rather than
|
|
190
|
+
// cleared on the next result — "all teardown has arrived" isn't observable. `success`
|
|
191
|
+
// results are never swallowed, so the flushed steer's completion always surfaces.
|
|
192
|
+
let interrupting = false
|
|
193
|
+
let interruptTimer = null
|
|
194
|
+
const INTERRUPT_SWALLOW_MS = 6000
|
|
180
195
|
// ── Haiku suggestion fallback ──
|
|
181
196
|
// Claude Code's own `prompt_suggestion` rarely fires in /code — the binary suppresses it
|
|
182
197
|
// per-turn (cache_cold when a turn's tokens > 10k, rate_limit when throttled, first turn).
|
|
@@ -384,7 +399,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
384
399
|
// it; idempotent + no-op on clean transcripts. See transcript-sanitize.mjs.
|
|
385
400
|
if (resume) {
|
|
386
401
|
const healed = sanitizeSession(cwd || process.cwd(), resume)
|
|
387
|
-
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks}
|
|
402
|
+
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} malformed tool block(s) in the transcript before resume.\n`)
|
|
388
403
|
}
|
|
389
404
|
|
|
390
405
|
// One-shot Haiku fallback suggestion. Runs on the SAME subscription auth as the room
|
|
@@ -504,6 +519,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
504
519
|
}
|
|
505
520
|
case 'result':
|
|
506
521
|
if (m.session_id) sessionId = m.session_id
|
|
522
|
+
// Swallow the interrupt's redundant teardown results (see `interrupting` above):
|
|
523
|
+
// abort() already emitted the one canonical aborted boundary. `success` (and any
|
|
524
|
+
// non-teardown subtype) always emits; only the aborted/error_during_execution pair
|
|
525
|
+
// the interrupt churns out is dropped, and only inside the timer-bounded window.
|
|
526
|
+
if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
|
|
507
527
|
turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
|
|
508
528
|
turnActive = false // turn settled → stall watchdog stands down
|
|
509
529
|
restartCount = 0 // a successful turn refills the auto-restart budget
|
|
@@ -590,6 +610,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
590
610
|
// Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
|
|
591
611
|
// session alive for the next one. ac.abort() is teardown only (end()).
|
|
592
612
|
async abort() {
|
|
613
|
+
// Swallow the SDK's redundant interrupt teardown (aborted + error_during_execution +
|
|
614
|
+
// re-init) — we emit the one canonical boundary below. Set BEFORE interrupt so the
|
|
615
|
+
// teardown results, which arrive async on the query loop, are caught; the timer bounds
|
|
616
|
+
// the window so a genuine later turn failure still surfaces.
|
|
617
|
+
if (turnActive) {
|
|
618
|
+
interrupting = true
|
|
619
|
+
clearTimeout(interruptTimer)
|
|
620
|
+
interruptTimer = setTimeout(() => { interrupting = false }, INTERRUPT_SWALLOW_MS)
|
|
621
|
+
}
|
|
593
622
|
try { await q?.interrupt?.() } catch { /* noop */ }
|
|
594
623
|
// interrupt() stops the turn but emits NO terminal message. Without one the
|
|
595
624
|
// struct stream ends on a non-terminal event, so on resume the SDK treats the
|
|
@@ -599,7 +628,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
599
628
|
// SDK already surfaced one for the interrupt. (restored from 0.7.49)
|
|
600
629
|
if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
|
|
601
630
|
},
|
|
602
|
-
end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
631
|
+
end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
|
|
603
632
|
get sessionId() { return sessionId },
|
|
604
633
|
get mode() { return mode },
|
|
605
634
|
get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
|
package/package.json
CHANGED
package/transcript-sanitize.mjs
CHANGED
|
@@ -48,7 +48,8 @@ const ANTHROPIC_SERVER_TOOLS = new Set([
|
|
|
48
48
|
'tool_search_tool_bm25',
|
|
49
49
|
])
|
|
50
50
|
|
|
51
|
-
const
|
|
51
|
+
const STU_MARKER = '"server_tool_use"'
|
|
52
|
+
const TR_MARKER = '"tool_result"'
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Resolve a session's transcript path the same way Claude Code names it:
|
|
@@ -72,6 +73,38 @@ function fixContent(content) {
|
|
|
72
73
|
return n
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/* ─────────────────────────────────────────────────────────────
|
|
77
|
+
SECOND HEAL — interrupted-turn corruption (failure 2026-06-26):
|
|
78
|
+
a `tool_result` block stranded inside an ASSISTANT message.
|
|
79
|
+
|
|
80
|
+
When a turn is aborted/interrupted mid-tool-call, Claude Code can
|
|
81
|
+
persist a single assistant message holding BOTH the tool_use AND its
|
|
82
|
+
tool_result (plus stray thinking/text). Anthropic strictly 400s:
|
|
83
|
+
messages.N: tool_result blocks can only be in user messages
|
|
84
|
+
and the room wedges — every send re-hits message N and fails (exactly
|
|
85
|
+
what bricked the /code GLM session, transcript line 1196 / messages.165).
|
|
86
|
+
|
|
87
|
+
THE FIX: in an assistant content array, drop every stray tool_result
|
|
88
|
+
AND the tool_use block(s) it pairs with (same id) — removing the pair
|
|
89
|
+
together so no orphaned tool_use is left behind (an orphan would 400
|
|
90
|
+
differently: "tool_use ids were found without tool_result blocks").
|
|
91
|
+
The aborted call is dead history; dropping it lets the session resume.
|
|
92
|
+
If the message would be left empty, leave a placeholder text block
|
|
93
|
+
(Anthropic rejects a content-less assistant message). In-place, no row
|
|
94
|
+
split — so Claude Code's own uuid/parentUuid chain stays intact.
|
|
95
|
+
Returns the new content array + count removed. ───────────────────── */
|
|
96
|
+
function fixToolResultInAssistant(content) {
|
|
97
|
+
if (!Array.isArray(content)) return { removed: 0, content }
|
|
98
|
+
const resultIds = new Set()
|
|
99
|
+
for (const b of content) if (b && b.type === 'tool_result') resultIds.add(b.tool_use_id)
|
|
100
|
+
if (!resultIds.size) return { removed: 0, content } // no stray result here — nothing to do
|
|
101
|
+
const before = content.length
|
|
102
|
+
let next = content.filter((b) =>
|
|
103
|
+
b && b.type !== 'tool_result' && !(b.type === 'tool_use' && resultIds.has(b.id)))
|
|
104
|
+
if (next.length === 0) next = [{ type: 'text', text: '[interrupted turn removed by transcript-sanitize]' }]
|
|
105
|
+
return { removed: before - next.length, content: next }
|
|
106
|
+
}
|
|
107
|
+
|
|
75
108
|
/**
|
|
76
109
|
* Sanitize one transcript file in place. Returns { lines, blocks } counting the
|
|
77
110
|
* transcript lines rewritten and the individual blocks flipped. A missing,
|
|
@@ -84,14 +117,19 @@ export function sanitizeTranscript(file) {
|
|
|
84
117
|
} catch {
|
|
85
118
|
return { lines: 0, blocks: 0 } // no transcript yet (fresh session) — nothing to do
|
|
86
119
|
}
|
|
87
|
-
|
|
120
|
+
const fileHasStu = raw.includes(STU_MARKER)
|
|
121
|
+
const fileHasTr = raw.includes(TR_MARKER)
|
|
122
|
+
if (!fileHasStu && !fileHasTr) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
|
|
88
123
|
|
|
89
124
|
const rows = raw.split('\n')
|
|
90
125
|
let lines = 0
|
|
91
126
|
let blocks = 0
|
|
92
127
|
for (let i = 0; i < rows.length; i++) {
|
|
93
128
|
const row = rows[i]
|
|
94
|
-
if (!row
|
|
129
|
+
if (!row) continue
|
|
130
|
+
const rowHasStu = row.includes(STU_MARKER)
|
|
131
|
+
const rowHasTr = row.includes(TR_MARKER)
|
|
132
|
+
if (!rowHasStu && !rowHasTr) continue
|
|
95
133
|
let obj
|
|
96
134
|
try {
|
|
97
135
|
obj = JSON.parse(row)
|
|
@@ -99,8 +137,22 @@ export function sanitizeTranscript(file) {
|
|
|
99
137
|
continue // never touch a line we can't parse — leave it byte-identical
|
|
100
138
|
}
|
|
101
139
|
let n = 0
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
// Pass 1 — foreign server_tool_use → tool_use (any message).
|
|
141
|
+
if (rowHasStu) {
|
|
142
|
+
if (obj.message && typeof obj.message === 'object') n += fixContent(obj.message.content)
|
|
143
|
+
n += fixContent(obj.content)
|
|
144
|
+
}
|
|
145
|
+
// Pass 2 — stray tool_result inside an assistant message → drop it + its paired tool_use.
|
|
146
|
+
if (rowHasTr) {
|
|
147
|
+
if (obj.message && obj.message.role === 'assistant') {
|
|
148
|
+
const r = fixToolResultInAssistant(obj.message.content)
|
|
149
|
+
if (r.removed) { obj.message.content = r.content; n += r.removed }
|
|
150
|
+
}
|
|
151
|
+
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
|
|
152
|
+
const r = fixToolResultInAssistant(obj.content)
|
|
153
|
+
if (r.removed) { obj.content = r.content; n += r.removed }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
104
156
|
if (n > 0) {
|
|
105
157
|
rows[i] = JSON.stringify(obj)
|
|
106
158
|
lines++
|
|
@@ -130,3 +182,27 @@ export function sanitizeSession(cwd, sessionId) {
|
|
|
130
182
|
return { lines: 0, blocks: 0 }
|
|
131
183
|
}
|
|
132
184
|
}
|
|
185
|
+
|
|
186
|
+
/* ─────────────────────────────────────────────────────────────
|
|
187
|
+
CLI — manual recovery for a wedged transcript (the auto-heal runs on
|
|
188
|
+
every resume via claude-session.mjs; this is the break-glass tool):
|
|
189
|
+
node bridge/transcript-sanitize.mjs <path-to-transcript.jsonl>
|
|
190
|
+
node bridge/transcript-sanitize.mjs <cwd> <sessionId>
|
|
191
|
+
Prints what it healed. A backup (<file>.bak) is written before any
|
|
192
|
+
change so a bad heal is one `mv` from undone.
|
|
193
|
+
───────────────────────────────────────────────────────────── */
|
|
194
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
195
|
+
const a = process.argv.slice(2)
|
|
196
|
+
if (a.length === 0) {
|
|
197
|
+
process.stderr.write('usage: transcript-sanitize.mjs <transcript.jsonl> | <cwd> <sessionId>\n')
|
|
198
|
+
process.exit(2)
|
|
199
|
+
}
|
|
200
|
+
const file = a.length >= 2 ? transcriptPath(a[0], a[1]) : a[0]
|
|
201
|
+
if (!fs.existsSync(file)) { process.stderr.write(`not found: ${file}\n`); process.exit(1) }
|
|
202
|
+
fs.copyFileSync(file, `${file}.bak`) // break-glass backup
|
|
203
|
+
const r = sanitizeTranscript(file)
|
|
204
|
+
process.stdout.write(r.blocks
|
|
205
|
+
? `healed ${r.blocks} malformed tool block(s) across ${r.lines} line(s) in ${file}\n backup: ${file}.bak\n`
|
|
206
|
+
: `no malformed tool blocks found in ${file} (already clean)\n`)
|
|
207
|
+
process.exit(0)
|
|
208
|
+
}
|