thinkpool-pair 0.7.24 → 0.7.26

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,37 @@ 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 supports using alternative LLM providers that are compatible with the Anthropic API:
104
+
105
+ ### OpenRouter
106
+ To use OpenRouter instead of Anthropic directly:
107
+ ```bash
108
+ npx thinkpool-pair provider openrouter --token sk-or-... [--model anthropic/claude-3.5-sonnet]
109
+ ```
110
+
111
+ This will configure the bridge to use OpenRouter's API endpoint with your provided token. You can optionally specify a model.
112
+
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
+ ```
118
+
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
+ ```
124
+
125
+ ### Reset to Default
126
+ To reset back to using Anthropic directly:
127
+ ```bash
128
+ npx thinkpool-pair provider anthropic
129
+ ```
130
+
131
+ Provider configurations are stored in `~/.thinkpool-pair/provider.json` and apply to all bridges on the machine.
132
+
101
133
  Public anon creds are embedded (the same ones the web app ships). Override with
102
134
  `TP_SUPABASE_URL` / `TP_SUPABASE_ANON` if needed. Set `TP_NAME` to label yourself.
package/bridge.mjs CHANGED
@@ -143,8 +143,16 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
143
143
  // ACCOUNT, not per room. `login` links this device; `bind <ROOM> <dir>` maps a
144
144
  // session to a working dir on this machine; a bare invocation discovers + serves
145
145
  // every session you're in (a headless child bridge per room, run in its dir).
146
+ // Apply the per-machine LLM provider choice (GLM via Z.ai, or default Anthropic)
147
+ // BEFORE any child is spawned. The launchd service doesn't source ~/.zshrc, so a
148
+ // shell export can't reach a serviced bridge — provider.json is its own source
149
+ // of truth. Idempotent; a no-op for the default Anthropic provider. See provider.mjs.
150
+ const { applyProviderEnv } = await import('./provider.mjs')
151
+ applyProviderEnv()
152
+
146
153
  if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
147
154
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
155
+ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
148
156
  if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
149
157
 
150
158
  const room = (argv[0] || '').toUpperCase().trim()
@@ -589,9 +597,16 @@ function openStructured({ id, model, resume, log, commands, mode }) {
589
597
  // on the entry so the ANNOUNCE can hand it to clients that connect/reload
590
598
  // AFTER init (the one-time code-event would miss them), then re-announce.
591
599
  if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
592
- // Chrome events (mode / usage / clear) are transient state, not transcript —
593
- // broadcast + print them, but keep them out of the persisted/replayed log.
594
- 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
+ }
595
610
  if (!chrome) {
596
611
  entry.log.push(evt)
597
612
  if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
@@ -806,6 +821,16 @@ channel
806
821
  ctlLine('context cleared. You can continue with these answers in mind.')
807
822
  return
808
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
+ }
809
834
  s.session.sendTurn(text)
810
835
  echoYou()
811
836
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.24",
3
+ "version": "0.7.26",
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": {
@@ -13,6 +13,7 @@
13
13
  "service.mjs",
14
14
  "account.mjs",
15
15
  "auth-store.mjs",
16
+ "provider.mjs",
16
17
  "README.md"
17
18
  ],
18
19
  "scripts": {
package/provider.mjs ADDED
@@ -0,0 +1,152 @@
1
+ /* ─────────────────────────────────────────────────────────────
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.
10
+
11
+ applyProviderEnv() runs once at the top of bridge.mjs (the universal
12
+ entry point), BEFORE any child is spawned. It sets ANTHROPIC_BASE_URL
13
+ / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL on process.env. Because the
14
+ account-supervisor spawn (account.mjs) and the Agent-SDK env
15
+ (claude-session.mjs) both spread `...process.env`, the choice reaches
16
+ every code session with no other change. Default (no file, or
17
+ provider:"anthropic") = the host's regular Claude login, untouched.
18
+
19
+ 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
25
+ ───────────────────────────────────────────────────────────── */
26
+ import os from 'node:os'
27
+ import fs from 'node:fs'
28
+ import path from 'node:path'
29
+
30
+ const DIR = path.join(os.homedir(), '.thinkpool-pair')
31
+ const FILE = path.join(DIR, 'provider.json')
32
+
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
+ function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
41
+ function opt(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
42
+
43
+ export function loadProvider() {
44
+ try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch { return null }
45
+ }
46
+
47
+ export function saveProvider(p) {
48
+ ensureDir()
49
+ fs.writeFileSync(FILE, JSON.stringify(p, null, 2), { mode: 0o600 })
50
+ }
51
+
52
+ /**
53
+ * Merge the persisted provider choice (or TP_PROVIDER* env overrides) into
54
+ * 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
56
+ * vars, never clobber a host's real Claude login env. Env vars win over the
57
+ * file so a one-off launch can flip providers without writing anything.
58
+ * @returns {{provider:string,baseUrl?:string,model?:string,source:string}}
59
+ */
60
+ export function applyProviderEnv() {
61
+ const cfg = loadProvider() || {}
62
+ const provider = (process.env.TP_PROVIDER || cfg.provider || 'anthropic').toLowerCase()
63
+ if (provider === 'anthropic' || !provider) return { provider: 'anthropic', source: 'default' }
64
+
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
83
+ const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
84
+ const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
85
+ 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`)
87
+ return { provider: 'anthropic', source: 'incomplete' }
88
+ }
89
+ process.env.ANTHROPIC_BASE_URL = baseUrl
90
+ process.env.ANTHROPIC_AUTH_TOKEN = authToken
91
+ if (model) process.env.ANTHROPIC_MODEL = model
92
+ return { provider, baseUrl, model, source: cfg.provider ? 'file' : 'env' }
93
+ }
94
+
95
+ /** CLI handler for `thinkpool-pair provider [...]`. */
96
+ export async function runProvider(args) {
97
+ const sub = (args[0] || '').toLowerCase()
98
+
99
+ // ── show ──
100
+ if (!sub || sub === 'show' || sub === 'status') {
101
+ const cfg = loadProvider()
102
+ if (!cfg || cfg.provider === 'anthropic' || !cfg.provider) {
103
+ console.error('\n ◆ provider: anthropic (regular Claude login) — the default.\n')
104
+ } else {
105
+ console.error(`\n ◆ provider: ${cfg.provider}\n base url: ${cfg.baseUrl || '(unset)'}\n model: ${cfg.model || '(unset)'}\n token: ${cfg.authToken ? cfg.authToken.slice(0, 8) + '…' : '(unset)'}\n`)
106
+ }
107
+ return
108
+ }
109
+
110
+ // ── reset to Anthropic ──
111
+ if (sub === 'anthropic' || sub === 'clear' || sub === 'reset') {
112
+ try { fs.unlinkSync(FILE) } catch { /* noop */ }
113
+ console.error('\n ✓ provider reset to anthropic (regular Claude login).\n')
114
+ return
115
+ }
116
+
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 ──
140
+ 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) }
145
+ 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`)
147
+ return
148
+ }
149
+
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')
151
+ process.exit(1)
152
+ }