thinkpool-pair 0.7.26 → 0.7.28

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 CHANGED
@@ -100,35 +100,33 @@ npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY
100
100
 
101
101
  ## Alternative LLM Providers
102
102
 
103
- The bridge supports using alternative LLM providers that are compatible with the Anthropic API:
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).
104
108
 
105
- ### OpenRouter
106
- To use OpenRouter instead of Anthropic directly:
107
109
  ```bash
108
- npx thinkpool-pair provider openrouter --token sk-or-... [--model anthropic/claude-3.5-sonnet]
110
+ npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
109
111
  ```
110
112
 
111
- This will configure the bridge to use OpenRouter's API endpoint with your provided token. You can optionally specify a model.
113
+ Common base urls (the SDK appends `/v1/messages`):
112
114
 
113
- ### GLM (Z.ai)
114
- To use GLM via Z.ai's Anthropic-compatible endpoint:
115
- ```bash
116
- npx thinkpool-pair provider glm --token zai_...
117
- ```
115
+ | Provider | Base url |
116
+ |---------------|-----------------------------------|
117
+ | Z.ai GLM | `https://api.z.ai/api/anthropic` |
118
+ | OpenRouter | `https://openrouter.ai/api` |
118
119
 
119
- ### Custom Provider
120
- For any other Anthropic-compatible endpoint:
121
- ```bash
122
- npx thinkpool-pair provider custom --base https://your-endpoint.com --token your-token [--model model-name]
123
- ```
120
+ Show the current provider (`npx thinkpool-pair provider`), or reset back to your
121
+ regular Claude login:
124
122
 
125
- ### Reset to Default
126
- To reset back to using Anthropic directly:
127
123
  ```bash
128
124
  npx thinkpool-pair provider anthropic
129
125
  ```
130
126
 
131
- Provider configurations are stored in `~/.thinkpool-pair/provider.json` and apply to all bridges on the machine.
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.
132
130
 
133
131
  Public anon creds are embedded (the same ones the web app ships). Override with
134
132
  `TP_SUPABASE_URL` / `TP_SUPABASE_ANON` if needed. Set `TP_NAME` to label yourself.
package/bridge.mjs CHANGED
@@ -37,6 +37,7 @@ import { randomUUID } from 'node:crypto'
37
37
  import { createClient } from '@supabase/supabase-js'
38
38
  import { startClaudeSession } from './claude-session.mjs'
39
39
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
40
+ import { stampEvent } from './event-id.mjs'
40
41
 
41
42
  // Public client creds (the same anon values the web app ships — safe to embed).
42
43
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -588,11 +589,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
588
589
  openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
589
590
  return
590
591
  }
591
- // Stamp a wall-clock ts on every transcript event so the web client can
592
- // sort agent turns chronologically against room chat/whispers on reload
593
- // (the client merges the replayed log with persisted human lines). Used
594
- // for ordering only agent timestamps are never displayed.
595
- if (typeof evt.ts !== 'number') evt.ts = Date.now()
592
+ // Stamp a wall-clock ts AND a stable cid on every transcript event before
593
+ // it's logged + broadcast. ts: lets the web client sort agent turns
594
+ // chronologically against room chat/whispers on reload. cid: the room's
595
+ // replay-union dedupes ONLY by cid, and SDK events carry none — without an
596
+ // id, an event that arrives both live AND in a reconnect replay renders
597
+ // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
598
+ stampEvent(evt)
596
599
  // The init system event carries the session's slash command list. Stash it
597
600
  // on the entry so the ANNOUNCE can hand it to clients that connect/reload
598
601
  // AFTER init (the one-time code-event would miss them), then re-announce.
@@ -637,10 +640,22 @@ function openStructured({ id, model, resume, log, commands, mode }) {
637
640
  // from memory, delete its on-disk record (so a restart can't resurrect it), and tell
638
641
  // every client it's gone. Idempotent + id-scoped — no-ops for ids that aren't a
639
642
  // structured session, so it's safe to call from the PTY-oriented term-close path too.
643
+ // Resolve any unanswered permission resolvers to 'deny' and clear them. A card
644
+ // that's never answered (both clients gone, tab closed, or an abort) otherwise
645
+ // leaves the SDK's PreToolUse hook (claude-session.mjs) awaiting our promise
646
+ // forever — the structured turn freezes mid-flight. 'deny' is the fail-safe the
647
+ // hook already degrades to, so draining to deny is always safe.
648
+ function drainPending(s) {
649
+ if (!s?.pending) return
650
+ for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
651
+ s.pending.clear()
652
+ }
653
+
640
654
  function endStructured(id) {
641
655
  if (!id) return
642
656
  const s = sessions.get(id)
643
657
  if (s) {
658
+ drainPending(s)
644
659
  try { s.session?.end() } catch { /* noop */ }
645
660
  try { s.mockupWatcher?.close() } catch { /* noop */ }
646
661
  sessions.delete(id)
@@ -843,7 +858,7 @@ channel
843
858
  })
844
859
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
845
860
  const s = payload?.term && sessions.get(payload.term)
846
- if (s) s.session.abort()
861
+ if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
847
862
  })
848
863
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
849
864
  const s = payload?.term && sessions.get(payload.term)
@@ -916,7 +931,7 @@ channel
916
931
  setInterval(() => {
917
932
  if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
918
933
  process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
919
- process.exit(1)
934
+ shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
920
935
  }
921
936
  }, 15000).unref()
922
937
 
@@ -960,7 +975,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
960
975
  const applyIfIdle = () => {
961
976
  if (!pendingUpdate || !isIdle()) return
962
977
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
963
- process.exit(0) // service restarts with @latest; session-store resumes
978
+ shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
964
979
  }
965
980
  const check = async () => {
966
981
  const latest = await fetchLatest()
@@ -1010,24 +1025,47 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1010
1025
  }, 60000).unref()
1011
1026
  }
1012
1027
 
1013
- async function shutdown() {
1028
+ // code: process exit code. farewell: print the "[ shared session ended ]" banner
1029
+ // (suppressed for an auto-update restart, which is meant to be invisible in-room).
1030
+ async function shutdown(code = 0, farewell = true) {
1014
1031
  if (shuttingDown) return
1015
1032
  shuttingDown = true
1033
+ // Hard exit backstop, armed FIRST and independent of every await below — a wedged
1034
+ // realtime socket (the watchdog path) or a hung untrack must NEVER leave the
1035
+ // process alive with orphaned PTY children + stale "live" presence.
1036
+ setTimeout(() => process.exit(code), 1500)
1016
1037
  clearInterval(flushTimer)
1017
- try {
1018
- const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
1019
- for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
1020
- } catch { /* noop */ }
1021
- // Leave presence EXPLICITLY before exiting so the web room flips to dormant
1022
- // immediately. Previously we removeChannel()'d and process.exit()'d on the next
1023
- // line the leave frame never flushed, so the web only noticed via the realtime
1024
- // heartbeat timeout (tens of seconds later) and looked stuck "live".
1025
- try { await channel.untrack() } catch { /* noop */ }
1026
- try { await supabase.removeChannel(channel) } catch { /* noop */ }
1038
+ if (farewell) {
1039
+ try {
1040
+ const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
1041
+ for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
1042
+ } catch { /* noop */ }
1043
+ }
1044
+ // Reap children FIRST killing PTYs / ending SDK sessions is unconditionally
1045
+ // correct and must not hang on the (possibly dead) realtime socket. This is the
1046
+ // bug behind the watchdog/auto-update raw process.exit() paths, which skipped
1047
+ // teardown entirely and orphaned agent processes + left stale presence.
1027
1048
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
1028
1049
  for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
1029
1050
  detachLocal()
1030
- setTimeout(() => process.exit(0), 250) // small grace for the leave/close frames to flush over the socket
1051
+ // Best-effort presence leave so the room flips to dormant immediately (when the
1052
+ // socket is alive; on a wedge it won't flush and the hard backstop above wins).
1053
+ try { await channel.untrack() } catch { /* noop */ }
1054
+ try { await supabase.removeChannel(channel) } catch { /* noop */ }
1055
+ setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
1031
1056
  }
1032
- process.on('SIGINT', shutdown)
1033
- process.on('SIGTERM', shutdown)
1057
+ process.on('SIGINT', () => shutdown(0))
1058
+ process.on('SIGTERM', () => shutdown(0))
1059
+ // A throw escaping any async callback (an onEvent consumer, a JSON-serialize on a
1060
+ // circular payload, a node-pty native error) would otherwise terminate the process
1061
+ // with PTYs orphaned and presence stuck "live" (Node terminates on both since v15).
1062
+ // Route both through shutdown() so children are reaped + presence left, then the
1063
+ // supervisor / launchd / systemd respawns a clean process.
1064
+ process.on('uncaughtException', (err) => {
1065
+ try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
1066
+ shutdown(1)
1067
+ })
1068
+ process.on('unhandledRejection', (reason) => {
1069
+ try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
1070
+ shutdown(1)
1071
+ })
@@ -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/event-id.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ /* event-id.mjs — stamp a structured event with a stable identity + timestamp
4
+ BEFORE it is logged + broadcast.
5
+
6
+ WHY (the duplicate-message bug, 2026-06-19): structured transcript events
7
+ (assistant / tool_result / thinking / result) carry NO id from the Claude
8
+ Agent SDK. The web room's replay-union (src/pages/code/room.jsx, the
9
+ `code-replay` handler) dedupes ONLY by `cid`: an event with a cid is merged
10
+ once; an event WITHOUT a cid is appended unconditionally. So an id-less event
11
+ that arrives both LIVE (`code-event`) and again in a reconnect REPLAY
12
+ (`code-replay`, which re-sends the whole rolling log) is rendered TWICE.
13
+ On mobile a background/foreground or network blip routinely triggers that
14
+ replay — which is exactly when the duplicates appeared.
15
+
16
+ Fix at the source: give every structured event a stable `cid` here, once, so
17
+ the live copy and every replayed copy share an id and the room's existing
18
+ cid-union collapses them. Idempotent — never overwrites a cid the client
19
+ already set (chat / whisper / pool / propose carry their own).
20
+
21
+ Kept as its own import-safe module so it can be unit-tested
22
+ (bridge/test-structured-cid.mjs); bridge.mjs runs the bridge on import and
23
+ cannot be imported by a test. */
24
+ export function stampEvent(evt, gen = randomUUID) {
25
+ if (!evt || typeof evt !== 'object') return evt
26
+ if (typeof evt.ts !== 'number') evt.ts = Date.now()
27
+ if (!evt.cid) evt.cid = gen()
28
+ return evt
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.26",
3
+ "version": "0.7.28",
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,8 @@
9
9
  "files": [
10
10
  "bridge.mjs",
11
11
  "claude-session.mjs",
12
+ "event-id.mjs",
13
+ "transcript-sanitize.mjs",
12
14
  "session-store.mjs",
13
15
  "service.mjs",
14
16
  "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 GLM (Z.ai's Anthropic-compatible
4
- endpoint) instead of plain Anthropic, INDEPENDENT of the launching
5
- shell. Why this exists: the launchd service does NOT source ~/.zshrc,
6
- so a shell `export ANTHROPIC_*` can never reach a serviced bridge —
7
- its spawned Claude children fall back to the host's claude.ai login
8
- and hit the Anthropic spend wall. provider.json is the bridge's OWN
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,12 +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 # show current
21
- npx thinkpool-pair provider glm --token <zai_…> # use GLM (Z.ai)
22
- npx thinkpool-pair provider openrouter --token <sk-or-…> [--model <model>]
23
- npx thinkpool-pair provider custom --base <url> --token <key>
24
- 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
25
30
  ───────────────────────────────────────────────────────────── */
26
31
  import os from 'node:os'
27
32
  import fs from 'node:fs'
@@ -30,13 +35,6 @@ import path from 'node:path'
30
35
  const DIR = path.join(os.homedir(), '.thinkpool-pair')
31
36
  const FILE = path.join(DIR, 'provider.json')
32
37
 
33
- // The pre-baked GLM/Z.ai preset. --token is always required (never bake a key
34
- // into source); --base / --model override these if the endpoint ever moves.
35
- const GLM = { baseUrl: 'https://api.z.ai/api/anthropic', model: 'glm-5.2' }
36
-
37
- // The pre-baked OpenRouter preset. --token is always required.
38
- const OPENROUTER = { baseUrl: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet' }
39
-
40
38
  function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
41
39
  function opt(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
42
40
 
@@ -50,11 +48,15 @@ export function saveProvider(p) {
50
48
  }
51
49
 
52
50
  /**
53
- * Merge the persisted provider choice (or TP_PROVIDER* env overrides) into
51
+ * Merge the persisted provider choice (or TP_* env overrides) into
54
52
  * process.env. Call once at startup, before spawning any child. Idempotent.
55
- * A no-op for the default Anthropic provider — we only ever SET the GLM/custom
53
+ * A no-op for the default Anthropic provider — we only ever SET the custom
56
54
  * vars, never clobber a host's real Claude login env. Env vars win over the
57
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.
58
60
  * @returns {{provider:string,baseUrl?:string,model?:string,source:string}}
59
61
  */
60
62
  export function applyProviderEnv() {
@@ -62,28 +64,11 @@ export function applyProviderEnv() {
62
64
  const provider = (process.env.TP_PROVIDER || cfg.provider || 'anthropic').toLowerCase()
63
65
  if (provider === 'anthropic' || !provider) return { provider: 'anthropic', source: 'default' }
64
66
 
65
- // Handle OpenRouter specifically
66
- if (provider === 'openrouter') {
67
- const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl || OPENROUTER.baseUrl
68
- const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
69
- const model = process.env.TP_ANTHROPIC_MODEL || cfg.model || OPENROUTER.model
70
-
71
- if (!authToken) {
72
- process.stderr.write(`\n ◇ provider "${provider}" is set but is missing authToken.\n Finish setup: npx thinkpool-pair provider ${provider} --token <key>\n Falling back to regular Claude for now.\n`)
73
- return { provider: 'anthropic', source: 'incomplete' }
74
- }
75
-
76
- process.env.ANTHROPIC_BASE_URL = baseUrl
77
- process.env.ANTHROPIC_AUTH_TOKEN = authToken
78
- if (model) process.env.ANTHROPIC_MODEL = model
79
- return { provider, baseUrl, model, source: cfg.provider ? 'file' : 'env' }
80
- }
81
-
82
- const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl
67
+ const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl
83
68
  const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
84
- const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
69
+ const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
85
70
  if (!baseUrl || !authToken) {
86
- process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: npx thinkpool-pair provider ${provider} --token <key>\n Falling back to regular Claude for now.\n`)
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`)
87
72
  return { provider: 'anthropic', source: 'incomplete' }
88
73
  }
89
74
  process.env.ANTHROPIC_BASE_URL = baseUrl
@@ -114,39 +99,34 @@ export async function runProvider(args) {
114
99
  return
115
100
  }
116
101
 
117
- // ── GLM (Z.ai) preset ──
118
- if (sub === 'glm') {
119
- const token = opt(args, '--token') || opt(args, '--key')
120
- const baseUrl = opt(args, '--base') || opt(args, '--base-url') || GLM.baseUrl
121
- const model = opt(args, '--model') || GLM.model
122
- if (!token) { console.error('\n ✗ glm needs --token <zai_…>. Get one at https://z.ai/\n'); process.exit(1) }
123
- saveProvider({ provider: 'glm', baseUrl, authToken: token, model, savedAt: Date.now() })
124
- 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`)
125
- return
126
- }
127
-
128
- // ── OpenRouter preset ──
129
- if (sub === 'openrouter') {
130
- const token = opt(args, '--token') || opt(args, '--key')
131
- const baseUrl = opt(args, '--base') || opt(args, '--base-url') || OPENROUTER.baseUrl
132
- const model = opt(args, '--model') || OPENROUTER.model
133
- if (!token) { console.error('\n ✗ openrouter needs --token <sk-or-…>. Get one at https://openrouter.ai/\n'); process.exit(1) }
134
- saveProvider({ provider: 'openrouter', baseUrl, authToken: token, model, savedAt: Date.now() })
135
- console.error(`\n ✓ provider set to openrouter.\n base url: ${baseUrl}\n model: ${model}\n token: ${token.slice(0, 8)}…\n Restart the bridge (or its launchd service) to apply.\n`)
136
- return
137
- }
138
-
139
- // ── arbitrary Anthropic-compatible endpoint ──
102
+ // ── any Anthropic-compatible endpoint ──
140
103
  if (sub === 'custom') {
141
- const token = opt(args, '--token') || opt(args, '--key')
142
- const baseUrl = opt(args, '--base') || opt(args, '--base-url')
143
- const model = opt(args, '--model')
144
- if (!token || !baseUrl) { console.error('\n ✗ custom needs --base <url> and --token <key>.\n'); process.exit(1) }
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
+ }
145
117
  saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
146
- console.error(`\n ✓ provider set to custom (${baseUrl}).\n Restart the bridge to apply.\n`)
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`)
147
119
  return
148
120
  }
149
121
 
150
- console.error('\n usage:\n npx thinkpool-pair provider # show current\n npx thinkpool-pair provider glm --token <zai_…> [--model glm-5.2]\n npx thinkpool-pair provider openrouter --token <sk-or-…> [--model <model>]\n npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>]\n npx thinkpool-pair provider anthropic # reset to Claude\n')
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
+ )
151
131
  process.exit(1)
152
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
+ }