thinkpool-pair 0.7.80 → 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 +23 -14
- package/bridge.mjs +52 -1
- package/byok-detect.mjs +82 -0
- package/claude-session.mjs +13 -1
- package/cross-terminal.mjs +100 -0
- package/launcher.mjs +40 -4
- package/package.json +5 -2
- package/service.mjs +13 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# thinkpool-pair — the ThinkPool Code bridge
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
##
|
|
117
|
+
## Run any model
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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).
|
package/byok-detect.mjs
ADDED
|
@@ -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
|
+
}
|
package/claude-session.mjs
CHANGED
|
@@ -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,8 @@ 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.',
|
|
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.',
|
|
367
375
|
].join(' '),
|
|
368
376
|
// Needed for live thinking-token progress (SDKThinkingTokensMessage) to
|
|
369
377
|
// flow during a turn. We ignore the fine-grained stream_event partials in
|
|
@@ -385,6 +393,10 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
385
393
|
// CLAUDE.md / agents either way — strict only governs MCP server sourcing. Flip
|
|
386
394
|
// this to the default (or curate a slim allowlist) once the numbers justify it.
|
|
387
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 }
|
|
388
400
|
if (cwd) opts.cwd = cwd
|
|
389
401
|
if (model) opts.model = model
|
|
390
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: '
|
|
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
|
-
],
|
|
179
|
-
if (p ===
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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}
|