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 +16 -18
- package/bridge.mjs +60 -22
- package/claude-session.mjs +11 -0
- package/event-id.mjs +29 -0
- package/package.json +3 -1
- package/provider.mjs +50 -70
- package/transcript-sanitize.mjs +132 -0
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
|
|
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
|
|
110
|
+
npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
Common base urls (the SDK appends `/v1/messages`):
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
+
})
|
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/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.
|
|
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
|
|
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,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
|
|
21
|
-
npx thinkpool-pair provider
|
|
22
|
-
npx thinkpool-pair provider
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
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
|
|
142
|
-
const baseUrl = opt(args, '--base')
|
|
143
|
-
const model
|
|
144
|
-
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
|
+
}
|
|
145
117
|
saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
|
|
146
|
-
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`)
|
|
147
119
|
return
|
|
148
120
|
}
|
|
149
121
|
|
|
150
|
-
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
|
+
)
|
|
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
|
+
}
|