thinkpool-pair 0.7.81 → 0.7.82

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
@@ -1,8 +1,8 @@
1
1
  # thinkpool-pair — the ThinkPool Code bridge
2
2
 
3
- Share your local **Claude Code** (or any terminal CLI) into a ThinkPool Code
4
- room, live. Both people see the real terminal stream and can both drive it —
5
- no copy/paste.
3
+ Pair two people on one live **coding agent** — running **any model you choose**
4
+ (Claude, GPT, Gemini, Llama, or a local model) — in a ThinkPool Code room. Both
5
+ people see the live stream and can both drive it, no copy/paste.
6
6
 
7
7
  > A web page can't read your terminal directly (browser security). This tiny
8
8
  > helper is the bridge: it runs CLIs in PTYs and streams them to the room.
@@ -114,24 +114,33 @@ npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY
114
114
  - `resize` — web viewport size → headless PTYs only (the attached terminal
115
115
  follows your own TTY).
116
116
 
117
- ## Alternative LLM Providers
117
+ ## Run any model
118
118
 
119
- The bridge can run on **any Anthropic-compatible endpoint** instead of Anthropic
120
- directly — Z.ai GLM, OpenRouter, your own proxy, etc. **CLI-agent-compatible
121
- only:** the bridge spawns the `claude` CLI, which talks the Anthropic Messages
122
- API, so the base url must be a root the SDK can append `/v1/messages` to (an
123
- OpenAI-style `/v1/chat/completions` endpoint will not work).
119
+ ThinkPool Code runs **any model you choose** — not just Anthropic. The agent
120
+ talks the Anthropic Messages API, so you point it at any endpoint that speaks
121
+ that format:
122
+
123
+ - **Anthropic-compatible endpoints directly** — Z.ai GLM, OpenRouter, your own proxy.
124
+ - **Any other model via a gateway** — put a translator like **LiteLLM** or
125
+ OpenRouter in front and run **GPT, Gemini, Llama, DeepSeek, or a local model**;
126
+ the gateway presents the Anthropic Messages API while calling whatever you pick.
124
127
 
125
128
  ```bash
126
129
  npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
127
130
  ```
128
131
 
129
- Common base urls (the SDK appends `/v1/messages`):
132
+ Common base urls (the agent appends `/v1/messages`):
133
+
134
+ | Provider / gateway | Base url | Models |
135
+ |--------------------|-----------------------------------|------------------------------|
136
+ | Z.ai GLM | `https://api.z.ai/api/anthropic` | GLM |
137
+ | OpenRouter | `https://openrouter.ai/api` | many, incl. non-Anthropic |
138
+ | LiteLLM (your own) | `http://localhost:4000` | GPT, Gemini, Llama, local, … |
130
139
 
131
- | Provider | Base url |
132
- |---------------|-----------------------------------|
133
- | Z.ai GLM | `https://api.z.ai/api/anthropic` |
134
- | OpenRouter | `https://openrouter.ai/api` |
140
+ > The endpoint must serve the Anthropic Messages API (`/v1/messages`). A raw
141
+ > OpenAI-style `/v1/chat/completions` won't work directly — that's exactly what a
142
+ > gateway is for. Avoid LiteLLM 1.82.7 / 1.82.8 (compromised releases); use a
143
+ > current version.
135
144
 
136
145
  Show the current provider (`npx thinkpool-pair provider`), or reset back to your
137
146
  regular Claude login:
package/bridge.mjs CHANGED
@@ -36,7 +36,10 @@ import { spawn, execSync } from 'node:child_process'
36
36
  import { fileURLToPath } from 'node:url'
37
37
  import { randomUUID } from 'node:crypto'
38
38
  import { createClient } from '@supabase/supabase-js'
39
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
40
+ import { z } from 'zod'
39
41
  import { startClaudeSession } from './claude-session.mjs'
42
+ import { formatPeek, PEEK } from './cross-terminal.mjs'
40
43
  import { turnInFlight } from './update-gate.mjs'
41
44
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
42
45
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
@@ -293,7 +296,11 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
293
296
  const { acquireMachineLock, releaseMachineLock, shouldHoldMachineLock } = await import('./account.mjs')
294
297
  if (shouldHoldMachineLock(process.env) && !acquireMachineLock()) {
295
298
  console.error('\n ◇ A thinkpool-pair bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Only one can be active, and the running\n one already serves every room you are in — use it, or stop it first.\n (If you are certain none is running, delete that stale lock file.)\n')
296
- process.exit(1)
299
+ // Intentional stand-down → exit 0 so a supervisor (systemd RestartPreventExitStatus=0 /
300
+ // launchd KeepAlive.SuccessfulExit=false) does NOT relaunch us into a thrash loop.
301
+ // Matches account.mjs's stand-down exit(0). Was exit(1), which read as a crash and
302
+ // got relentlessly restarted (the 2026-06-28 account-bridge restart loop).
303
+ process.exit(0)
297
304
  }
298
305
  if (shouldHoldMachineLock(process.env)) process.on('exit', () => { try { releaseMachineLock() } catch { /* noop */ } })
299
306
  }
@@ -553,9 +560,23 @@ const announce = () =>
553
560
  })
554
561
 
555
562
  // One flush timer batches every terminal's pending bytes (~35ms cadence).
563
+ //
564
+ // Viewer gate (2026-06-28): pty-out is the single biggest Realtime-message
565
+ // generator (~28 frames/sec/terminal). A bridge serving a room whose PTY keeps
566
+ // emitting (a dev server, a log tail, a running agent) would broadcast that
567
+ // stream into an EMPTY channel — nobody watching — at full cadence, 24/7. With
568
+ // no public users that was the entire Realtime bill (~2.4M msgs/day per chatty
569
+ // unwatched terminal). So: when no web peer is present we still DRAIN buf (keep
570
+ // memory bounded — scrollback already holds the history) but do NOT broadcast.
571
+ // A viewer who arrives later gets the full catch-up via replay-request →
572
+ // scrollback, the same path a reconnect already uses. lastActivity is still
573
+ // stamped so an actively-working-but-unwatched agent isn't seen as idle and
574
+ // restarted mid-output by the auto-update gate.
556
575
  const flushAll = () => {
576
+ const watched = webPeerPresent()
557
577
  for (const [id, t] of terms) {
558
578
  if (!t.buf) continue
579
+ if (!watched) { t.buf = ''; lastActivity = Date.now(); continue }
559
580
  const b64 = Buffer.from(t.buf, 'utf8').toString('base64')
560
581
  t.buf = ''
561
582
  bcast('pty-out', { term: id, b64 })
@@ -878,8 +899,37 @@ function openStructured({ id, model, resume, log, commands, mode }) {
878
899
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
879
900
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
880
901
  entry.flush = () => flushSession(room, id, sessionData())
902
+ // B2 cross-terminal peek — an in-process, READ-ONLY tool the agent can call to
903
+ // see a SIBLING terminal's recent output (vs B1's always-on @pool digest). The
904
+ // handler closes over the module-scope `sessions`/`terms` Maps + this session's
905
+ // own `id` (excluded) + `entry` (per-turn call counter, reset by the code-turn
906
+ // handler). Auto-allowed by the PreToolUse gate (mcp__thinkpool__ prefix).
907
+ // Spec: docs/specs/2026-06-28-cross-terminal-awareness.md
908
+ const peekServer = createSdkMcpServer({
909
+ name: 'thinkpool',
910
+ version: '1.0.0',
911
+ tools: [
912
+ tool(
913
+ 'read_terminal',
914
+ 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list the other open terminals; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use it when your work depends on what another terminal is doing.',
915
+ {
916
+ terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
917
+ lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
918
+ },
919
+ async (args) => {
920
+ entry.peekCount = (entry.peekCount || 0) + 1
921
+ if (entry.peekCount > PEEK.perTurnCap) {
922
+ return { content: [{ type: 'text', text: `Cross-terminal read limit reached for this turn (${PEEK.perTurnCap}). Continue with what you have, or ask the people in the room.` }] }
923
+ }
924
+ const text = formatPeek({ selfId: id, sessions, terms, terminal: args?.terminal, lines: args?.lines })
925
+ return { content: [{ type: 'text', text }] }
926
+ },
927
+ ),
928
+ ],
929
+ })
881
930
  entry.session = startClaudeSession({
882
931
  cwd: process.cwd(), model, resume, mode,
932
+ mcpServers: { thinkpool: peekServer },
883
933
  env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
884
934
  onEvent: (evt) => {
885
935
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
@@ -1166,6 +1216,7 @@ channel
1166
1216
  const s = payload?.term && sessions.get(payload.term)
1167
1217
  if (!s || payload.text == null) return
1168
1218
  markActivity()
1219
+ s.peekCount = 0 // B2: fresh cross-terminal read budget per user turn
1169
1220
  const text = String(payload.text)
1170
1221
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
1171
1222
  // said what — UNLESS silent (an @pool fire, which renders its own line).
@@ -0,0 +1,82 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ byok-detect — "paste a key → pick a model" for the bridge menu.
3
+ ThinkPool Code is structured-only (Anthropic Messages API), so only
4
+ endpoints that speak that format work directly. OpenRouter is the
5
+ universal path: one key, any model, an Anthropic-compatible endpoint.
6
+ A bare OpenAI/Gemini/Groq key is DETECTED so we can guide the user to
7
+ OpenRouter or a gateway instead of failing silently.
8
+ Spec: docs/specs/2026-06-28-byok-only-two-tier-pivot.md (addendum)
9
+ ───────────────────────────────────────────────────────────── */
10
+
11
+ // Providers whose endpoint speaks the Anthropic Messages API → usable directly
12
+ // in structured mode. `base` is what we set as ANTHROPIC_BASE_URL; `models` is
13
+ // the model-list endpoint; `auth` is how the model-list call authenticates.
14
+ export const ANTHROPIC_COMPATIBLE = {
15
+ anthropic: {
16
+ name: 'Anthropic',
17
+ base: 'https://api.anthropic.com',
18
+ models: 'https://api.anthropic.com/v1/models',
19
+ auth: 'x-api-key',
20
+ },
21
+ openrouter: {
22
+ name: 'OpenRouter — any model',
23
+ base: 'https://openrouter.ai/api',
24
+ models: 'https://openrouter.ai/api/v1/models',
25
+ auth: 'bearer',
26
+ },
27
+ }
28
+
29
+ // Key-prefix → provider. `compatible: true` means it works directly; false
30
+ // means we detected it but it needs OpenRouter or a gateway (different wire
31
+ // format). Keep the most specific prefixes first (sk-ant- / sk-or- before sk-).
32
+ const PREFIXES = [
33
+ ['sk-ant-', 'anthropic', true, 'Anthropic'],
34
+ ['sk-or-', 'openrouter', true, 'OpenRouter'],
35
+ ['AIza', 'google', false, 'Google Gemini'],
36
+ ['gsk_', 'groq', false, 'Groq'],
37
+ ['pplx-', 'perplexity', false, 'Perplexity'],
38
+ ['xai-', 'xai', false, 'xAI (Grok)'],
39
+ ['fw_', 'fireworks', false, 'Fireworks'],
40
+ ['sk-', 'openai', false, 'OpenAI'], // generic sk- LAST (after sk-ant-/sk-or-)
41
+ ]
42
+
43
+ // Detect the provider from a pasted key. Returns { id, name, compatible }.
44
+ export function detectProvider(key) {
45
+ const k = String(key || '').trim()
46
+ if (!k) return { id: 'unknown', name: 'unknown', compatible: false }
47
+ for (const [prefix, id, compatible, name] of PREFIXES) {
48
+ if (k.startsWith(prefix)) return { id, name, compatible }
49
+ }
50
+ return { id: 'unknown', name: 'unknown', compatible: false }
51
+ }
52
+
53
+ // Fetch the model-id list for an Anthropic-compatible provider. Handles both
54
+ // OpenAI-shape ({ data: [{ id }] }) and Google-shape ({ models: [{ name }] }).
55
+ // Returns [] on any failure (caller falls back to a free-text model prompt).
56
+ export async function fetchModels({ modelsUrl, key, auth }) {
57
+ const headers = auth === 'x-api-key'
58
+ ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
59
+ : { Authorization: `Bearer ${key}` }
60
+ const ctrl = new AbortController()
61
+ const timer = setTimeout(() => ctrl.abort(), 12_000)
62
+ try {
63
+ const r = await fetch(modelsUrl, { headers, signal: ctrl.signal, redirect: 'error' })
64
+ if (!r.ok) return []
65
+ const data = await r.json()
66
+ const raw = Array.isArray(data?.data) ? data.data
67
+ : Array.isArray(data?.models) ? data.models
68
+ : []
69
+ return raw
70
+ .map((m) => String(m.id || m.name || '').replace(/^models\//, ''))
71
+ .filter(Boolean)
72
+ } catch {
73
+ return []
74
+ } finally {
75
+ clearTimeout(timer)
76
+ }
77
+ }
78
+
79
+ // The friendly message shown when a detected key isn't directly usable.
80
+ export function gatewayHint(name) {
81
+ return `That key looks like ${name}. ThinkPool Code runs on the Anthropic Messages API, so it can't use that key directly. Easiest fix: paste an OpenRouter key (sk-or-…) — one key, every model (GPT, Gemini, Llama, and more). Or point it at your own gateway (e.g. LiteLLM).`
82
+ }
@@ -120,7 +120,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
120
120
  */
121
121
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
122
122
 
123
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission }) {
123
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers }) {
124
124
  const ac = new AbortController()
125
125
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
126
126
  let sessionId = resume || null
@@ -248,6 +248,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
248
248
  // Surface it to the room when it changes; absent on models without effort.
249
249
  const lvl = hookInput.effort?.level
250
250
  if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
251
+ // ThinkPool's own in-process tools (B2 cross-terminal read) are read-only and
252
+ // within-room — never prompt for them, in any mode. They still surface as
253
+ // tool_use/tool_result cards so the room sees the peek.
254
+ if (typeof toolName === 'string' && toolName.startsWith('mcp__thinkpool__')) {
255
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
256
+ }
251
257
  // ── Plan approval — ExitPlanMode is how the agent presents its plan in
252
258
  // plan mode. Render a dedicated plan card (not the generic perm card) with
253
259
  // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
@@ -364,6 +370,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
364
370
  'SHOW YOUR WORK, do not just describe it: the room is watched live from a phone, where a wall of text is painful to read. Whenever you build, change, or fix anything visual — a UI, a page, an HTML artifact, a chart, a diagram, a rendered result — capture a screenshot and surface the PNG (for example, read the image file into your turn) so it appears inline in the room. The room auto-uploads any image you surface. Default to showing a picture of the result over narrating it; err toward more screenshots, not fewer.',
365
371
  'HTML PREVIEWS: for any HTML you produce, give the room something it can actually open, by whichever path is available. (a) IN-ROOM PREVIEW: if a mockup render helper is present — one that writes a manifest into the directory named by $TP_MOCKUP_OUTBOX, such as the mockup-iterate render.sh script when the bridge was launched from the thinkpool repo — use it; it surfaces an inline card with desktop and mobile views that the room can expand. (b) SHAREABLE LINK: additionally or otherwise, publish the HTML to a GitHub Pages URL per the LINKS & ARTIFACTS rule above and post the link. Never leave an HTML artifact viewable only as a local path.',
366
372
  'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
373
+ 'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal.',
367
374
  'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
368
375
  ].join(' '),
369
376
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
@@ -386,6 +393,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
386
393
  // CLAUDE.md / agents either way — strict only governs MCP server sourcing. Flip
387
394
  // this to the default (or curate a slim allowlist) once the numbers justify it.
388
395
  if (process.env.TP_MCP_STRICT === '1') { opts.strictMcpConfig = true; opts.mcpServers = {} }
396
+ // In-process ThinkPool tools (B2 read_terminal) — MERGED with the host's
397
+ // filesystem MCP (non-strict), or the sole servers under TP_MCP_STRICT. The SDK
398
+ // namespaces these as mcp__<key>__<tool>; the PreToolUse gate auto-allows them.
399
+ if (mcpServers && Object.keys(mcpServers).length) opts.mcpServers = { ...(opts.mcpServers || {}), ...mcpServers }
389
400
  if (cwd) opts.cwd = cwd
390
401
  if (model) opts.model = model
391
402
  if (resume) opts.resume = resume
@@ -0,0 +1,100 @@
1
+ /* ThinkPool Code — B2 cross-terminal peek (read-only sibling access).
2
+ Pure formatting for the in-process `read_terminal` MCP tool that bridge.mjs
3
+ wires into every structured Claude session. Lets an agent PULL a sibling
4
+ terminal's recent output on demand (vs B1's always-on @pool digest) so it can
5
+ reason across lanes when its own work depends on another terminal.
6
+
7
+ Bridge-local on purpose — it cannot import the frontend's code-constants, so
8
+ the structured-event compaction here mirrors that file's digestLine shape.
9
+ Spec: docs/specs/2026-06-28-cross-terminal-awareness.md */
10
+
11
+ // Bounds — the pull tool is self-limiting (the agent only calls it when it needs
12
+ // to), but a runaway loop must still be capped (the cost-guard / C10 class).
13
+ export const PEEK = {
14
+ defaultLines: 40,
15
+ maxLines: 200,
16
+ perTurnCap: 10, // read_terminal calls allowed per user turn (bridge resets it)
17
+ lineCap: 200, // per-line truncation
18
+ rosterPreview: 100, // last-line preview length in the roster listing
19
+ }
20
+
21
+ const clip = (s, cap) => {
22
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
23
+ return t.length > cap ? t.slice(0, cap - 1) + '…' : t
24
+ }
25
+
26
+ // Compact ONE structured log event into a single line, or '' to skip it.
27
+ // Mirrors src/pages/code/code-constants.js digestLine — keep them in step.
28
+ export const digestLogEvent = (e, cap = PEEK.lineCap) => {
29
+ if (!e || typeof e !== 'object') return ''
30
+ switch (e.kind) {
31
+ case 'you': return clip(`${e.by || 'someone'}: ${e.text || ''}`, cap)
32
+ case 'assistant': {
33
+ const parts = []
34
+ for (const b of (Array.isArray(e.blocks) ? e.blocks : [])) {
35
+ if (b?.type === 'text' && String(b.text || '').trim()) parts.push(String(b.text).trim())
36
+ else if (b?.type === 'tool_use' && b.name) parts.push(`→ ${b.name}`)
37
+ }
38
+ return parts.length ? clip(`assistant: ${parts.join(' ')}`, cap) : ''
39
+ }
40
+ case 'tool_result': return e.isError ? clip('⎿ error', cap) : ''
41
+ case 'result': return (e.subtype && e.subtype !== 'success') ? clip(`turn ended — ${e.subtype}`, cap) : ''
42
+ case 'error': return clip(`error: ${e.message || ''}`, cap)
43
+ default: return '' // chat/whisper/pool/note/control/compaction/system → not lane work
44
+ }
45
+ }
46
+
47
+ // Strip ANSI / control bytes from a PTY scrollback buffer, return last N real lines.
48
+ export const cleanPtyTail = (buf, n) => {
49
+ /* eslint-disable no-control-regex -- stripping terminal escape/control bytes is the whole point */
50
+ const txt = String(buf || '')
51
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
52
+ .replace(/\x1b[@-_][0-?]*[ -/]*[@-~]/g, '') // CSI / escape sequences
53
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') // remaining control chars
54
+ /* eslint-enable no-control-regex */
55
+ const lines = txt.split(/\r?\n/).map(l => l.replace(/\s+$/, '')).filter(l => l.trim())
56
+ return lines.slice(-Math.max(1, n))
57
+ }
58
+
59
+ // Build the tool's text reply. `selfId` is excluded. `sessions` (structured) and
60
+ // `terms` (PTY) are the bridge's module-scope Maps (id → entry). With no
61
+ // `terminal`, returns the roster; with one, returns that lane's recent output.
62
+ export const formatPeek = ({ selfId, sessions, terms, terminal, lines } = {}) => {
63
+ const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
64
+ const sib = []
65
+ for (const [id, s] of (sessions || new Map())) {
66
+ if (id === selfId) continue
67
+ sib.push({ id, kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
68
+ }
69
+ for (const [id, t] of (terms || new Map())) {
70
+ if (id === selfId) continue
71
+ sib.push({ id, kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
72
+ }
73
+ if (!sib.length) return 'No other terminals are open in this room right now.'
74
+
75
+ const ref = (x) => String(x.id).slice(0, 8)
76
+
77
+ if (!terminal) {
78
+ const rows = sib.map(x => {
79
+ let preview
80
+ if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
81
+ else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
82
+ return `- ${ref(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
83
+ })
84
+ return `Other terminals in this room (call read_terminal with one of these refs to read it):\n${rows.join('\n')}`
85
+ }
86
+
87
+ const q = String(terminal).toLowerCase().trim()
88
+ const hit = sib.find(x => String(x.id).toLowerCase() === q)
89
+ || sib.find(x => ref(x).toLowerCase() === q || String(x.id).toLowerCase().startsWith(q))
90
+ || sib.find(x => String(x.cmd).toLowerCase().includes(q))
91
+ if (!hit) return `No terminal matches "${terminal}". Call read_terminal with no argument to list the open terminals.`
92
+
93
+ let body
94
+ if (hit.kind === 'agent') {
95
+ body = hit.log.map(e => digestLogEvent(e, PEEK.lineCap)).filter(Boolean).slice(-n).join('\n') || '(no activity yet)'
96
+ } else {
97
+ body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
98
+ }
99
+ return `Terminal ${ref(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
100
+ }
package/launcher.mjs CHANGED
@@ -14,6 +14,7 @@ import os from 'node:os'
14
14
  import fs from 'node:fs'
15
15
  import path from 'node:path'
16
16
  import { execSync } from 'node:child_process'
17
+ import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
17
18
 
18
19
  const HOME = os.homedir()
19
20
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -173,12 +174,47 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
173
174
  const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
174
175
  const p = await askChoice('\n provider', [
175
176
  { label: 'Anthropic (default)', hint: 'regular Claude login' },
176
- { label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' },
177
+ { label: 'Paste a key, pick a model', hint: 'auto-detects the provider · OpenRouter = any model' },
178
+ { label: 'Custom endpoint', hint: 'enter a base url + token by hand' },
177
179
  { label: '← Back', hint: `current: ${cur}` },
178
- ], 3) // Enter = Back
179
- if (p === 2) return
180
+ ], 4) // Enter = Back
181
+ if (p === 3) return
180
182
  if (p === 0) { await actions.setProvider({ kind: 'anthropic' }); resync(); io.print('\n ' + C.green('✓ back on regular Claude.')); return }
181
- // custom — every step can be aborted with a blank line, which backs out cleanly.
183
+
184
+ if (p === 1) {
185
+ // Paste a key → detect provider → auto base url → fetch model list → pick.
186
+ const key = (await io.ask(`\n Paste your API key ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
187
+ if (!key) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
188
+ const det = detectProvider(key)
189
+ if (!det.compatible) { io.print('\n ' + C.yellow(gatewayHint(det.name))); return }
190
+ const cfg = ANTHROPIC_COMPATIBLE[det.id]
191
+ io.print(`\n ${C.green('✓ detected ' + cfg.name)} ${C.dim('— fetching models…')}`)
192
+ const models = await fetchModels({ modelsUrl: cfg.models, key, auth: cfg.auth })
193
+ let model
194
+ if (models.length === 0) {
195
+ model = (await io.ask(` Couldn't list models. Model id ${C.dim('(blank = endpoint default)')} ${C.cyan('▸')} `)).trim() || undefined
196
+ } else if (models.length <= 40) {
197
+ const idx = await askChoice('\n model', models.map((m) => ({ label: m })), 1)
198
+ model = models[idx]
199
+ } else {
200
+ io.print(` ${C.dim(models.length + ' models available, e.g. ' + models.slice(0, 3).join(', ') + '…')}`)
201
+ for (;;) {
202
+ const q = (await io.ask(` Type a model id or part of it ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
203
+ if (!q) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
204
+ const matches = models.filter((m) => m.toLowerCase().includes(q.toLowerCase()))
205
+ if (matches.length === 0) { io.print(' ' + C.yellow('no match — try again')); continue }
206
+ if (matches.length === 1) { model = matches[0]; break }
207
+ if (matches.length <= 40) { const i = await askChoice('\n pick', matches.map((m) => ({ label: m })), 1); model = matches[i]; break }
208
+ io.print(` ${C.yellow(matches.length + ' matches — narrow it down')}`)
209
+ }
210
+ }
211
+ await actions.setProvider({ kind: 'custom', base: cfg.base, token: key, model })
212
+ resync()
213
+ io.print(`\n ${C.green('✓ provider set.')}\n ${cfg.name}\n model: ${model || '(endpoint default)'}`)
214
+ return
215
+ }
216
+
217
+ // p === 2 — manual custom endpoint (kept as a fallback; blank line aborts each step).
182
218
  const base = (await io.ask(`\n Base url ${C.dim('(blank to cancel · SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
183
219
  if (!base) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
184
220
  const token = (await io.ask(` API token ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.81",
3
+ "version": "0.7.82",
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,11 +9,13 @@
9
9
  "files": [
10
10
  "bridge.mjs",
11
11
  "launcher.mjs",
12
+ "byok-detect.mjs",
12
13
  "claude-session.mjs",
13
14
  "update-gate.mjs",
14
15
  "event-id.mjs",
15
16
  "transcript-sanitize.mjs",
16
17
  "session-store.mjs",
18
+ "cross-terminal.mjs",
17
19
  "service.mjs",
18
20
  "presence.mjs",
19
21
  "account.mjs",
@@ -29,7 +31,8 @@
29
31
  },
30
32
  "dependencies": {
31
33
  "@anthropic-ai/claude-agent-sdk": "0.3.185",
32
- "@supabase/supabase-js": "^2.45.0"
34
+ "@supabase/supabase-js": "^2.45.0",
35
+ "zod": "^4.4.3"
33
36
  },
34
37
  "optionalDependencies": {
35
38
  "node-pty": "^1.2.0-beta.13"
package/service.mjs CHANGED
@@ -85,7 +85,13 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
85
85
  <key>Label</key><string>${label(room)}</string>
86
86
  <key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join('')}</array>
87
87
  <key>RunAtLoad</key><true/>
88
- <key>KeepAlive</key><true/>
88
+ <!-- KeepAlive only on UNSUCCESSFUL exit. A bridge that exits 0 has intentionally
89
+ stood down — told to stop, or another bridge already owns this machine's
90
+ singleton lock — and must NOT be relaunched, or launchd thrashes it every
91
+ few seconds forever (the 2026-06-28 account-bridge restart loop). A crash
92
+ or an update-restart exits non-zero and IS relaunched. Mirrors the bridge's
93
+ own exit-code contract (stop()=0, restart-requested=non-zero). -->
94
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
89
95
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
90
96
  <key>StandardOutPath</key><string>${xml(log)}</string>
91
97
  <key>StandardErrorPath</key><string>${xml(log)}</string>
@@ -127,6 +133,12 @@ After=network-online.target
127
133
  ExecStart=${execStart}
128
134
  Restart=always
129
135
  RestartSec=2
136
+ # Don't relaunch a clean stand-down. exit 0 = the bridge intentionally stopped —
137
+ # told to stop, or another bridge already holds this machine's singleton lock —
138
+ # so restarting it just thrashes every RestartSec forever (the 2026-06-28
139
+ # account-bridge restart loop). Crashes + update-restarts exit non-zero and are
140
+ # still restarted. Matches the bridge's exit-code contract (stop()=0, restart=non-zero).
141
+ RestartPreventExitStatus=0
130
142
  WorkingDirectory=${cwd}
131
143
  Environment=PATH=${process.env.PATH || ''}
132
144
  ${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}