thinkpool-pair 0.7.75 → 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.
- package/claude-session.mjs +1 -1
- package/package.json +1 -1
- package/session-store.mjs +35 -10
- package/transcript-sanitize.mjs +81 -5
package/claude-session.mjs
CHANGED
|
@@ -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}
|
|
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
package/session-store.mjs
CHANGED
|
@@ -12,14 +12,33 @@ import os from 'node:os'
|
|
|
12
12
|
import fs from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
|
|
16
|
+
// ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
|
|
17
|
+
// can point each case at a fresh tempdir after importing this module. Production leaves
|
|
18
|
+
// it unset → the real home-dir store.
|
|
19
|
+
const ROOT = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
|
|
20
|
+
const dir = (room) => path.join(ROOT(), room || 'default')
|
|
21
|
+
const archiveDir = (room) => path.join(dir(room), '.archive')
|
|
17
22
|
// Only resume the live SDK context for recent sessions — an expired session id
|
|
18
23
|
// fails ("No conversation found"); past this window we restore transcript only.
|
|
19
24
|
const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
|
|
20
|
-
const KEEP = 8 // newest N session files per room
|
|
25
|
+
const KEEP = 8 // newest N UNNAMED session files per room (named are immune — see prune)
|
|
21
26
|
|
|
22
27
|
function ensureDir(room) { try { fs.mkdirSync(dir(room), { recursive: true }) } catch { /* noop */ } }
|
|
28
|
+
// Move a session file into <room>/.archive/ instead of unlinking it. Archived files
|
|
29
|
+
// are OUTSIDE the readdirSync(dir) path loadAll/listRecs read, so a bridge restart
|
|
30
|
+
// never resurrects them — but they survive on disk for manual recovery. This is the
|
|
31
|
+
// data-loss backstop: a pruned/closed terminal's transcript is always recoverable.
|
|
32
|
+
// (2026-06-26: prune used to unlinkSync, permanently destroying older terminals —
|
|
33
|
+
// "Blightborne" was lost this way. Named terminals are now additionally immune.)
|
|
34
|
+
function archive(room, file) {
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(archiveDir(room), { recursive: true })
|
|
37
|
+
const base = path.basename(file)
|
|
38
|
+
const dst = path.join(archiveDir(room), `${base}.${Date.now()}`)
|
|
39
|
+
fs.renameSync(file, dst)
|
|
40
|
+
} catch { /* noop */ }
|
|
41
|
+
}
|
|
23
42
|
function listRecs(room) {
|
|
24
43
|
try {
|
|
25
44
|
return fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
|
|
@@ -29,10 +48,15 @@ function listRecs(room) {
|
|
|
29
48
|
}
|
|
30
49
|
function prune(room) {
|
|
31
50
|
try {
|
|
51
|
+
const names = loadNames(room) // { id: label } — NAMED terminals are immune
|
|
52
|
+
const namedId = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')).id } catch { return null } }
|
|
32
53
|
const files = fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
|
|
33
|
-
.map((f) => { const p = path.join(dir(room), f); let m = 0; try { m = JSON.parse(fs.readFileSync(p, 'utf8')).savedAt || 0 } catch { /* noop */ } return { p, m } })
|
|
54
|
+
.map((f) => { const p = path.join(dir(room), f); let m = 0; try { m = JSON.parse(fs.readFileSync(p, 'utf8')).savedAt || 0 } catch { /* noop */ } return { p, f, m } })
|
|
34
55
|
.sort((a, b) => b.m - a.m)
|
|
35
|
-
|
|
56
|
+
// Only UNNAMED sessions are eligible for prune. A named terminal is deliberate
|
|
57
|
+
// user intent (a project) and must never be silently destroyed by churn.
|
|
58
|
+
const prunable = files.filter((x) => { const id = namedId(x.p); return !id || !names[id] })
|
|
59
|
+
prunable.slice(KEEP).forEach((x) => archive(room, x.p))
|
|
36
60
|
} catch { /* noop */ }
|
|
37
61
|
}
|
|
38
62
|
|
|
@@ -46,13 +70,14 @@ export function saveSession(room, id, data) {
|
|
|
46
70
|
timers.set(id, setTimeout(() => write(room, id, data), 1500))
|
|
47
71
|
}
|
|
48
72
|
export function flushSession(room, id, data) { clearTimeout(timers.get(id)); write(room, id, data) }
|
|
49
|
-
// User-initiated close: cancel any pending debounced save and
|
|
50
|
-
// record so a later loadAll() (bridge restart) can't resurrect a
|
|
51
|
-
//
|
|
52
|
-
// harmlessly for them.
|
|
73
|
+
// User-initiated close: cancel any pending debounced save and move the on-disk
|
|
74
|
+
// record to .archive/ so a later loadAll() (bridge restart) can't resurrect a
|
|
75
|
+
// closed terminal — but the transcript stays recoverable. PTY ids never have an
|
|
76
|
+
// <id>.json (they use the .pty-id dotfile), so this no-ops harmlessly for them.
|
|
53
77
|
export function deleteSession(room, id) {
|
|
54
78
|
clearTimeout(timers.get(id)); timers.delete(id)
|
|
55
|
-
|
|
79
|
+
const p = path.join(dir(room), `${id}.json`)
|
|
80
|
+
try { if (fs.existsSync(p)) archive(room, p) } catch { /* noop */ }
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
// Most-recently-saved structured session for the room (drives attached restore).
|
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
|
+
}
|