thinkpool-pair 0.7.26 → 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 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.
@@ -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.26",
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 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
+ }