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 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 transcript —
601
- // broadcast + print them, but keep them out of the persisted/replayed log.
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
  })
@@ -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.25",
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,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 # show current
21
- npx thinkpool-pair provider glm --token <zai_…> # use GLM (Z.ai)
22
- npx thinkpool-pair provider custom --base <url> --token <key>
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 TP_PROVIDER* env overrides) into
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 GLM/custom
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 = process.env.TP_ANTHROPIC_BASE_URL || cfg.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 = process.env.TP_ANTHROPIC_MODEL || cfg.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 ${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`)
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
- // ── GLM (Z.ai) preset ──
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 = opt(args, '--token') || opt(args, '--key')
110
- const baseUrl = opt(args, '--base') || opt(args, '--base-url')
111
- const model = opt(args, '--model')
112
- 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
+ }
113
117
  saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
114
- 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`)
115
119
  return
116
120
  }
117
121
 
118
- 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 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
+ )
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
+ }