thinkpool-pair 0.7.25 → 0.7.27
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/README.md +30 -0
- package/bridge.mjs +20 -3
- package/claude-session.mjs +11 -0
- package/package.json +2 -1
- package/provider.mjs +50 -38
- package/transcript-sanitize.mjs +132 -0
package/README.md
CHANGED
|
@@ -98,5 +98,35 @@ npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY
|
|
|
98
98
|
- `resize` — web viewport size → headless PTYs only (the attached terminal
|
|
99
99
|
follows your own TTY).
|
|
100
100
|
|
|
101
|
+
## Alternative LLM Providers
|
|
102
|
+
|
|
103
|
+
The bridge can run on **any Anthropic-compatible endpoint** instead of Anthropic
|
|
104
|
+
directly — Z.ai GLM, OpenRouter, your own proxy, etc. **CLI-agent-compatible
|
|
105
|
+
only:** the bridge spawns the `claude` CLI, which talks the Anthropic Messages
|
|
106
|
+
API, so the base url must be a root the SDK can append `/v1/messages` to (an
|
|
107
|
+
OpenAI-style `/v1/chat/completions` endpoint will not work).
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Common base urls (the SDK appends `/v1/messages`):
|
|
114
|
+
|
|
115
|
+
| Provider | Base url |
|
|
116
|
+
|---------------|-----------------------------------|
|
|
117
|
+
| Z.ai GLM | `https://api.z.ai/api/anthropic` |
|
|
118
|
+
| OpenRouter | `https://openrouter.ai/api` |
|
|
119
|
+
|
|
120
|
+
Show the current provider (`npx thinkpool-pair provider`), or reset back to your
|
|
121
|
+
regular Claude login:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx thinkpool-pair provider anthropic
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Provider config is stored in `~/.thinkpool-pair/provider.json` (mode 0600) and
|
|
128
|
+
applies to every bridge on the machine. Restart the bridge (or its launchd
|
|
129
|
+
service) after changing it — the choice is read once at startup.
|
|
130
|
+
|
|
101
131
|
Public anon creds are embedded (the same ones the web app ships). Override with
|
|
102
132
|
`TP_SUPABASE_URL` / `TP_SUPABASE_ANON` if needed. Set `TP_NAME` to label yourself.
|
package/bridge.mjs
CHANGED
|
@@ -597,9 +597,16 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
597
597
|
// on the entry so the ANNOUNCE can hand it to clients that connect/reload
|
|
598
598
|
// AFTER init (the one-time code-event would miss them), then re-announce.
|
|
599
599
|
if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
|
|
600
|
-
// Chrome events (mode / usage / clear) are transient state, not
|
|
601
|
-
// broadcast + print them
|
|
602
|
-
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear'
|
|
600
|
+
// Chrome events (mode / usage / clear / compact) are transient state, not
|
|
601
|
+
// transcript — broadcast + print them but keep out of the persisted/replayed log.
|
|
602
|
+
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact'
|
|
603
|
+
// compact turn finished (or errored) → clear the pulsing indicator.
|
|
604
|
+
// No "done" ctl line — the SDK's own output in the transcript is the
|
|
605
|
+
// signal (compact summary on success, AbortError text on abort/too-small).
|
|
606
|
+
if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
|
|
607
|
+
entry.compacting = false
|
|
608
|
+
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
609
|
+
}
|
|
603
610
|
if (!chrome) {
|
|
604
611
|
entry.log.push(evt)
|
|
605
612
|
if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
|
|
@@ -814,6 +821,16 @@ channel
|
|
|
814
821
|
ctlLine('context cleared. You can continue with these answers in mind.')
|
|
815
822
|
return
|
|
816
823
|
}
|
|
824
|
+
// /compact → announce immediately, track so onEvent can emit a done signal when
|
|
825
|
+
// the SDK turn finishes. No echoYou — the ctl line IS the announcement.
|
|
826
|
+
if (/^\/compact\b/.test(text)) {
|
|
827
|
+
ctlLine('◈ compacting context…')
|
|
828
|
+
s.compacting = true
|
|
829
|
+
s.compactBy = payload.by
|
|
830
|
+
bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
|
|
831
|
+
s.session.sendTurn(text)
|
|
832
|
+
return
|
|
833
|
+
}
|
|
817
834
|
s.session.sendTurn(text)
|
|
818
835
|
echoYou()
|
|
819
836
|
})
|
package/claude-session.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
16
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
17
|
+
import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
17
18
|
|
|
18
19
|
// ── risk classification — the accent/danger tier of the permission card ──
|
|
19
20
|
// low (read-only) · medium (writes/runs) · network (leaves the machine) ·
|
|
@@ -290,6 +291,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
290
291
|
if (resume) opts.resume = resume
|
|
291
292
|
if (env) opts.env = env
|
|
292
293
|
|
|
294
|
+
// Before the SDK replays this transcript, heal any cross-provider tool blocks
|
|
295
|
+
// it left behind (e.g. a room that ran on Z.ai GLM, now back on anthropic).
|
|
296
|
+
// Anthropic 400s on a `server_tool_use` block carrying a foreign id/name, which
|
|
297
|
+
// wedges the room on every send. Reclassifying those to plain `tool_use` clears
|
|
298
|
+
// it; idempotent + no-op on clean transcripts. See transcript-sanitize.mjs.
|
|
299
|
+
if (resume) {
|
|
300
|
+
const healed = sanitizeSession(cwd || process.cwd(), resume)
|
|
301
|
+
if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
|
|
302
|
+
}
|
|
303
|
+
|
|
293
304
|
;(async () => {
|
|
294
305
|
try {
|
|
295
306
|
q = query({ prompt: input.stream, options: opts })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.27",
|
|
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": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
11
|
"claude-session.mjs",
|
|
12
|
+
"transcript-sanitize.mjs",
|
|
12
13
|
"session-store.mjs",
|
|
13
14
|
"service.mjs",
|
|
14
15
|
"account.mjs",
|
package/provider.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/* ─────────────────────────────────────────────────────────────
|
|
2
2
|
provider.mjs — per-machine LLM provider choice for the ThinkPool
|
|
3
|
-
bridge. Lets a Code room run on
|
|
4
|
-
|
|
5
|
-
shell. Why this exists: the launchd
|
|
6
|
-
so a shell `export ANTHROPIC_*` can
|
|
7
|
-
its spawned Claude children fall back
|
|
8
|
-
and hit the Anthropic spend wall.
|
|
9
|
-
source of truth.
|
|
3
|
+
bridge. Lets a Code room run on ANY Anthropic-compatible endpoint
|
|
4
|
+
(Z.ai GLM, OpenRouter, a local proxy, …) instead of plain Anthropic,
|
|
5
|
+
INDEPENDENT of the launching shell. Why this exists: the launchd
|
|
6
|
+
service does NOT source ~/.zshrc, so a shell `export ANTHROPIC_*` can
|
|
7
|
+
never reach a serviced bridge — its spawned Claude children fall back
|
|
8
|
+
to the host's claude.ai login and hit the Anthropic spend wall.
|
|
9
|
+
provider.json is the bridge's OWN source of truth.
|
|
10
10
|
|
|
11
11
|
applyProviderEnv() runs once at the top of bridge.mjs (the universal
|
|
12
12
|
entry point), BEFORE any child is spawned. It sets ANTHROPIC_BASE_URL
|
|
@@ -16,11 +16,17 @@
|
|
|
16
16
|
every code session with no other change. Default (no file, or
|
|
17
17
|
provider:"anthropic") = the host's regular Claude login, untouched.
|
|
18
18
|
|
|
19
|
+
CLI-agent-compatible ONLY. The bridge spawns the `claude` CLI, which
|
|
20
|
+
talks the Anthropic Messages API — so the base url MUST be an
|
|
21
|
+
Anthropic-compatible root (the SDK appends /v1/messages). An OpenAI-
|
|
22
|
+
style /v1/chat/completions endpoint will NOT work. Common roots:
|
|
23
|
+
Z.ai GLM → https://api.z.ai/api/anthropic
|
|
24
|
+
OpenRouter → https://openrouter.ai/api
|
|
25
|
+
|
|
19
26
|
CLI (dispatched from bridge.mjs):
|
|
20
|
-
npx thinkpool-pair provider
|
|
21
|
-
npx thinkpool-pair provider
|
|
22
|
-
npx thinkpool-pair provider
|
|
23
|
-
npx thinkpool-pair provider anthropic # reset to Claude
|
|
27
|
+
npx thinkpool-pair provider # show current
|
|
28
|
+
npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>] # any Anthropic-compatible endpoint
|
|
29
|
+
npx thinkpool-pair provider anthropic # reset to Claude
|
|
24
30
|
───────────────────────────────────────────────────────────── */
|
|
25
31
|
import os from 'node:os'
|
|
26
32
|
import fs from 'node:fs'
|
|
@@ -29,10 +35,6 @@ import path from 'node:path'
|
|
|
29
35
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
30
36
|
const FILE = path.join(DIR, 'provider.json')
|
|
31
37
|
|
|
32
|
-
// The pre-baked GLM/Z.ai preset. --token is always required (never bake a key
|
|
33
|
-
// into source); --base / --model override these if the endpoint ever moves.
|
|
34
|
-
const GLM = { baseUrl: 'https://api.z.ai/api/anthropic', model: 'glm-5.2' }
|
|
35
|
-
|
|
36
38
|
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
|
|
37
39
|
function opt(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
|
|
38
40
|
|
|
@@ -46,11 +48,15 @@ export function saveProvider(p) {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
* Merge the persisted provider choice (or
|
|
51
|
+
* Merge the persisted provider choice (or TP_* env overrides) into
|
|
50
52
|
* process.env. Call once at startup, before spawning any child. Idempotent.
|
|
51
|
-
* A no-op for the default Anthropic provider — we only ever SET the
|
|
53
|
+
* A no-op for the default Anthropic provider — we only ever SET the custom
|
|
52
54
|
* vars, never clobber a host's real Claude login env. Env vars win over the
|
|
53
55
|
* file so a one-off launch can flip providers without writing anything.
|
|
56
|
+
*
|
|
57
|
+
* Provider-name-agnostic: any provider saved to provider.json (glm, custom,
|
|
58
|
+
* a leftover openrouter file, …) is handled by one branch that only cares
|
|
59
|
+
* that baseUrl/authToken are present.
|
|
54
60
|
* @returns {{provider:string,baseUrl?:string,model?:string,source:string}}
|
|
55
61
|
*/
|
|
56
62
|
export function applyProviderEnv() {
|
|
@@ -58,11 +64,11 @@ export function applyProviderEnv() {
|
|
|
58
64
|
const provider = (process.env.TP_PROVIDER || cfg.provider || 'anthropic').toLowerCase()
|
|
59
65
|
if (provider === 'anthropic' || !provider) return { provider: 'anthropic', source: 'default' }
|
|
60
66
|
|
|
61
|
-
const baseUrl
|
|
67
|
+
const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl
|
|
62
68
|
const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
|
|
63
|
-
const model
|
|
69
|
+
const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
|
|
64
70
|
if (!baseUrl || !authToken) {
|
|
65
|
-
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: npx thinkpool-pair provider
|
|
71
|
+
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: npx thinkpool-pair provider custom --base <url> --token <key>\n Falling back to regular Claude for now.\n`)
|
|
66
72
|
return { provider: 'anthropic', source: 'incomplete' }
|
|
67
73
|
}
|
|
68
74
|
process.env.ANTHROPIC_BASE_URL = baseUrl
|
|
@@ -93,28 +99,34 @@ export async function runProvider(args) {
|
|
|
93
99
|
return
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
// ──
|
|
97
|
-
if (sub === 'glm') {
|
|
98
|
-
const token = opt(args, '--token') || opt(args, '--key')
|
|
99
|
-
const baseUrl = opt(args, '--base') || opt(args, '--base-url') || GLM.baseUrl
|
|
100
|
-
const model = opt(args, '--model') || GLM.model
|
|
101
|
-
if (!token) { console.error('\n ✗ glm needs --token <zai_…>. Get one at https://z.ai/\n'); process.exit(1) }
|
|
102
|
-
saveProvider({ provider: 'glm', baseUrl, authToken: token, model, savedAt: Date.now() })
|
|
103
|
-
console.error(`\n ✓ provider set to glm (Z.ai).\n base url: ${baseUrl}\n model: ${model}\n token: ${token.slice(0, 8)}…\n Restart the bridge (or its launchd service) to apply.\n`)
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── arbitrary Anthropic-compatible endpoint ──
|
|
102
|
+
// ── any Anthropic-compatible endpoint ──
|
|
108
103
|
if (sub === 'custom') {
|
|
109
|
-
const token
|
|
110
|
-
const baseUrl = opt(args, '--base')
|
|
111
|
-
const model
|
|
112
|
-
if (!token || !baseUrl) {
|
|
104
|
+
const token = opt(args, '--token') || opt(args, '--key')
|
|
105
|
+
const baseUrl = opt(args, '--base') || opt(args, '--base-url')
|
|
106
|
+
const model = opt(args, '--model')
|
|
107
|
+
if (!token || !baseUrl) {
|
|
108
|
+
console.error(
|
|
109
|
+
'\n ✗ custom needs --base <url> and --token <key>. [--model <name>] is optional.\n' +
|
|
110
|
+
' The base url is the Anthropic-compatible root the SDK appends /v1/messages to:\n' +
|
|
111
|
+
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
112
|
+
' OpenRouter → https://openrouter.ai/api\n' +
|
|
113
|
+
' Restart the bridge (or its launchd service) to apply.\n'
|
|
114
|
+
)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
113
117
|
saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
|
|
114
|
-
console.error(`\n ✓ provider set to custom
|
|
118
|
+
console.error(`\n ✓ provider set to custom.\n base url: ${baseUrl}\n model: ${model || '(default)'}\n token: ${token.slice(0, 8)}…\n Restart the bridge (or its launchd service) to apply.\n`)
|
|
115
119
|
return
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
console.error(
|
|
122
|
+
console.error(
|
|
123
|
+
'\n usage:\n' +
|
|
124
|
+
' npx thinkpool-pair provider # show current\n' +
|
|
125
|
+
' npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>] # any Anthropic-compatible endpoint\n' +
|
|
126
|
+
' npx thinkpool-pair provider anthropic # reset to Claude\n\n' +
|
|
127
|
+
' common base urls (SDK appends /v1/messages):\n' +
|
|
128
|
+
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
129
|
+
' OpenRouter → https://openrouter.ai/api\n'
|
|
130
|
+
)
|
|
119
131
|
process.exit(1)
|
|
120
132
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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 MARKER = '"server_tool_use"'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a session's transcript path the same way Claude Code names it:
|
|
55
|
+
* ~/.claude/projects/<cwd with [/\.:] → '-'>/<sessionId>.jsonl
|
|
56
|
+
*/
|
|
57
|
+
export function transcriptPath(cwd, sessionId) {
|
|
58
|
+
const slug = path.resolve(cwd || process.cwd()).replace(/[/\\.:]/g, '-')
|
|
59
|
+
return path.join(os.homedir(), '.claude', 'projects', slug, `${sessionId}.jsonl`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Flip illegal server_tool_use blocks in one content array. Returns count flipped.
|
|
63
|
+
function fixContent(content) {
|
|
64
|
+
if (!Array.isArray(content)) return 0
|
|
65
|
+
let n = 0
|
|
66
|
+
for (const b of content) {
|
|
67
|
+
if (b && b.type === 'server_tool_use' && !ANTHROPIC_SERVER_TOOLS.has(b.name)) {
|
|
68
|
+
b.type = 'tool_use'
|
|
69
|
+
n++
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return n
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sanitize one transcript file in place. Returns { lines, blocks } counting the
|
|
77
|
+
* transcript lines rewritten and the individual blocks flipped. A missing,
|
|
78
|
+
* unreadable, or already-clean file is a silent no-op ({ lines: 0, blocks: 0 }).
|
|
79
|
+
*/
|
|
80
|
+
export function sanitizeTranscript(file) {
|
|
81
|
+
let raw
|
|
82
|
+
try {
|
|
83
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
84
|
+
} catch {
|
|
85
|
+
return { lines: 0, blocks: 0 } // no transcript yet (fresh session) — nothing to do
|
|
86
|
+
}
|
|
87
|
+
if (!raw.includes(MARKER)) return { lines: 0, blocks: 0 } // fast path: nothing illegal possible
|
|
88
|
+
|
|
89
|
+
const rows = raw.split('\n')
|
|
90
|
+
let lines = 0
|
|
91
|
+
let blocks = 0
|
|
92
|
+
for (let i = 0; i < rows.length; i++) {
|
|
93
|
+
const row = rows[i]
|
|
94
|
+
if (!row || !row.includes(MARKER)) continue
|
|
95
|
+
let obj
|
|
96
|
+
try {
|
|
97
|
+
obj = JSON.parse(row)
|
|
98
|
+
} catch {
|
|
99
|
+
continue // never touch a line we can't parse — leave it byte-identical
|
|
100
|
+
}
|
|
101
|
+
let n = 0
|
|
102
|
+
if (obj.message && typeof obj.message === 'object') n += fixContent(obj.message.content)
|
|
103
|
+
n += fixContent(obj.content)
|
|
104
|
+
if (n > 0) {
|
|
105
|
+
rows[i] = JSON.stringify(obj)
|
|
106
|
+
lines++
|
|
107
|
+
blocks += n
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!blocks) return { lines: 0, blocks: 0 } // every server_tool_use was already a legal Anthropic one
|
|
112
|
+
|
|
113
|
+
// Atomic write — a crash mid-write must never leave a truncated transcript.
|
|
114
|
+
const tmp = `${file}.tp-sanitize.tmp`
|
|
115
|
+
fs.writeFileSync(tmp, rows.join('\n'))
|
|
116
|
+
fs.renameSync(tmp, file)
|
|
117
|
+
return { lines, blocks }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve cwd+sessionId to a path and sanitize. Wrapped so a sanitize failure
|
|
122
|
+
* can never block session start — worst case the room behaves as it did before.
|
|
123
|
+
* Returns the same shape as sanitizeTranscript.
|
|
124
|
+
*/
|
|
125
|
+
export function sanitizeSession(cwd, sessionId) {
|
|
126
|
+
if (!sessionId) return { lines: 0, blocks: 0 }
|
|
127
|
+
try {
|
|
128
|
+
return sanitizeTranscript(transcriptPath(cwd, sessionId))
|
|
129
|
+
} catch {
|
|
130
|
+
return { lines: 0, blocks: 0 }
|
|
131
|
+
}
|
|
132
|
+
}
|