thinkpool-pair 0.7.76 → 0.7.77

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.
@@ -384,7 +384,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
384
384
  // it; idempotent + no-op on clean transcripts. See transcript-sanitize.mjs.
385
385
  if (resume) {
386
386
  const healed = sanitizeSession(cwd || process.cwd(), resume)
387
- if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
387
+ if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} malformed tool block(s) in the transcript before resume.\n`)
388
388
  }
389
389
 
390
390
  // One-shot Haiku fallback suggestion. Runs on the SAME subscription auth as the room
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.76",
3
+ "version": "0.7.77",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,7 +48,8 @@ const ANTHROPIC_SERVER_TOOLS = new Set([
48
48
  'tool_search_tool_bm25',
49
49
  ])
50
50
 
51
- const MARKER = '"server_tool_use"'
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
- if (!raw.includes(MARKER)) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
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 || !row.includes(MARKER)) continue
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
- if (obj.message && typeof obj.message === 'object') n += fixContent(obj.message.content)
103
- n += fixContent(obj.content)
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
+ }