thinkpool-pair 0.7.326 → 0.7.328

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,235 +1,272 @@
1
- # thinkpool-pair — the ThinkPool Code bridge
1
+ # `thinkpool-pair`
2
2
 
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.
3
+ `thinkpool-pair` connects coding agents running on a machine you control to a
4
+ Thinkpool Code room. Both people in the room can watch and steer the visible
5
+ agent lanes from a browser or phone.
6
6
 
7
- > A web page can't read your terminal directly (browser security). This tiny
8
- > helper is the bridge: it runs CLIs in PTYs and streams them to the room.
7
+ The bridge makes outbound connections to Thinkpool’s Supabase project. It does
8
+ not require an inbound port, tunnel, or public IP.
9
9
 
10
- > **Don't want it tied to your laptop?** Run this same bridge on an always-on box
11
- > you control (a cheap VM, a home server, a Raspberry Pi) so the agent stays live
12
- > with your laptop closed. It's outbound-only — no ports, no public IP. See
13
- > [**Run it in the cloud**](#run-it-in-the-cloud-remote-host--vm--container) below.
10
+ ## Requirements
14
11
 
15
- ## Run it (one command)
12
+ - Node.js 18 or newer
13
+ - A Thinkpool account and Code session
14
+ - At least one supported runtime available on the host:
15
+ - Claude
16
+ - Codex
17
+ - Hermes
18
+ - The provider login or API credentials required by that runtime
19
+
20
+ ## Start the bridge
21
+
22
+ Run the launcher from the project directory the agents should use:
16
23
 
17
24
  ```bash
18
- cd bridge && npm i # one-time (builds node-pty — needs Xcode CLT / build-essential)
19
- node bridge.mjs <ROOM> # picks an installed agent, shared into room <ROOM>
25
+ npx thinkpool-pair@latest
20
26
  ```
21
27
 
22
- - `<ROOM>` is the 5-char room code from the ThinkPool Code web UI (`/code`).
23
- - Share a specific CLI: `node bridge.mjs <ROOM> -- aider` / `… -- bash`.
24
- - Pure relay (no local terminal): `node bridge.mjs <ROOM> --headless`.
25
-
26
- ## Keep it stable (auto-restart)
27
-
28
- The bridge self-heals at three levels — all cross-platform (macOS / Linux / Windows):
29
-
30
- - **`--supervise`** — wrap it so a crash respawns automatically (exponential
31
- backoff). Zero-dependency. Combined with session restore (below), a crash is
32
- invisible — the room reconnects and the Claude session resumes where it left off.
33
- ```bash
34
- npx thinkpool-pair@latest <ROOM> --supervise -- claude
35
- ```
36
- - **Boot-persistent service** — survive reboot/logout too. Installs the right
37
- native service for your OS (launchd on macOS, systemd `--user` on Linux, a
38
- Startup-folder script on Windows):
39
- ```bash
40
- npx thinkpool-pair@latest install-service <ROOM> -- claude # set and forget
41
- npx thinkpool-pair@latest uninstall-service <ROOM> # remove it
42
- ```
43
- The service runs `npx thinkpool-pair@latest`, so it **auto-updates** — new
44
- versions apply on the next restart, no re-install. (Linux: run
45
- `loginctl enable-linger $USER` once to keep it running after logout.)
46
- - **Watchdog** — if the realtime channel wedges for >60s, the bridge exits so the
47
- supervisor/service restarts a clean process. Brief network blips reconnect on
48
- their own.
49
-
50
- Structured Claude sessions persist their scrollback + session id to
51
- `~/.thinkpool-pair/<ROOM>/` and **resume on restart** (live context if recent),
52
- so none of the above loses your place.
53
-
54
- ### Codex App Server driver
55
-
56
- Structured Codex lanes use the long-lived App Server transport (streaming prose,
57
- mid-turn steering, shared approval and question cards, and native image inputs)
58
- when the installed Codex CLI is in the bridge's tested protocol range. To force
59
- the stable `codex exec --json` fallback, set the kill switch before starting the
60
- bridge:
61
-
62
- ```bash
63
- export TP_CODEX_APP_SERVER=0
64
- ```
65
-
66
- The bridge performs the initialize handshake before creating a thread and falls
67
- back to `codex exec` when App Server is disabled, outside the tested version
68
- range, or cannot initialize before a turn. It never retries a prompt via the
69
- fallback after `turn/start` has been accepted, preventing duplicate work.
70
-
71
- Your first agent runs **attached** — use it exactly like your normal terminal,
72
- every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
73
- **headless** terminals here (same directory, same env), driven entirely from
74
- the room. One bridge, many terminals.
75
-
76
- ### Built-in viewport capture
77
-
78
- Structured Claude and Codex lanes have bridge-owned visual QA tools, even when
79
- their own sandbox cannot bind localhost or launch Chrome:
80
-
81
- - `preview_start` serves a built directory inside that lane's workspace
82
- (`dist` by default; it must contain `index.html`).
83
- - `preview_capture` returns exact desktop (1440×900) and mobile (390×844)
84
- screenshots to the agent as verification evidence. It creates no transcript
85
- artifact by default. Pass `card: true` only for an intentional user-facing
86
- mockup/Design deliverable; a settled complete pair is then queued after the
87
- agent's final response. Loading, empty, and public-auth fallback shells are
88
- rejected before a card can be created.
89
- - `preview_inspect` returns rendered DOM text, document size, and optional
90
- selector geometry at either viewport.
91
- - `preview_stop` releases the preview port.
92
-
93
- The bridge launches the host's Chrome/Chromium lazily. Set `TP_BROWSER_PATH`
94
- if it is installed somewhere non-standard. Preview files are read-only, roots
95
- cannot escape the lane workspace (including through symlinks), and page network
96
- requests are restricted to the preview server's exact loopback origin. The
97
- tools never execute a caller-supplied command or open an arbitrary URL.
98
-
99
- ## Run it in the cloud (remote host / VM / container)
100
-
101
- The bridge connects **outbound** to Supabase — no inbound ports, no public IP, no
102
- tunnel. So a cloud VM, container, devcontainer, or remote dev box can stream its
103
- Claude Code into the room exactly like a laptop. Run the same command there:
104
-
105
- ```bash
106
- export ANTHROPIC_API_KEY=sk-ant-... # headless auth (no Keychain login)
107
- cd /path/to/your/repo
108
- npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY needed
109
- ```
110
-
111
- - **Auth** — two ways to sign in on a box with no browser. Both are your own
112
- account on your own hardware, so both are fine:
113
- - **API key** — `export ANTHROPIC_API_KEY=sk-ant-...` from your Anthropic
114
- Console. Simplest, pay-per-token, cleanest cost tracking.
115
- - **Your Claude subscription** — a server has no browser, so mint a long-lived
116
- token *on a machine that does*: run `claude setup-token` there (prints a
117
- ~1-year `CLAUDE_CODE_OAUTH_TOKEN`), then `export CLAUDE_CODE_OAUTH_TOKEN=...`
118
- on the box. Uses your existing Pro/Max plan — no per-token bill.
119
-
120
- The Agent SDK reads either one automatically and runs its bundled `claude`, so
121
- you don't need Claude Code installed separately. (Note: this is *you* running
122
- *your* login on *your* machine. Handing a subscription token to a third-party
123
- host to run for you is what Anthropic's terms forbid — that's why a future
124
- "we host it for you" option would require an API key, not your subscription.)
125
- - **Use `@latest`**: a bare `npx thinkpool-pair` reuses npx's local cache and can
126
- run a stale version across restarts. `@latest` (and the service below) always
127
- re-resolve the newest publish.
128
- - **No TTY required**: the agent picker auto-selects and raw-mode is skipped when
129
- there's no terminal, so it runs fine under CI / a service / `nohup`.
130
- - **Always-on + auto-update**: `npx thinkpool-pair@latest install-service <ROOM> -- claude`
131
- installs a systemd `--user` unit (auto-restart + boot-persistent). The bridge
132
- polls npm for new publishes and **self-restarts at the next idle moment** into
133
- the new version — the structured session resumes, so an away/unattended box
134
- upgrades itself with nobody losing their place. On a server, also run
135
- `loginctl enable-linger $USER` so it survives logout.
136
- Tune with `THINKPOOL_PAIR_UPDATE_INTERVAL` (poll seconds, default 1800) and
137
- `THINKPOOL_PAIR_UPDATE_IDLE` (idle seconds before restart, default 90). Live
138
- auto-update applies to the launchd/systemd service tiers; bare `--supervise`
139
- respawns the same version, so it updates only on the next manual/boot restart.
140
- - **Security**: anyone with the room code can drive the agent (shell-trust by
141
- design) — on a server that's a real shell, so treat the room code like a secret.
142
-
143
- ## How it works
144
-
145
- `bridge.mjs` ⇄ **Supabase realtime** (`tpcode:<ROOM>`) ⇄ web `xterm`:
146
- - `bridge` — announce: installed agents + live terminals.
147
- - `pty-out` — terminal bytes (base64, per-terminal) → web clients render them.
148
- - `pty-in` — keystrokes/prompts from the web → written to that terminal's PTY.
149
- - `term-open` / `term-close` / `term-exit` — web-driven terminal lifecycle.
150
- - `replay-request` / `pty-replay` — each terminal keeps a rolling ~120 KB
151
- scrollback buffer for live recovery. Thinkpool also stores the cleaned reader
152
- transcript and room events so members can reopen the room. It does not store
153
- a copy of the repository or the raw PTY byte stream.
154
- - `resize` — web viewport size → headless PTYs only (the attached terminal
155
- follows your own TTY).
156
-
157
- ## Inspect the privacy boundary
158
-
159
- Run a local report before pairing a repository:
28
+ The launcher shows the linked account, detected runtimes, current directory,
29
+ and background-service state. On first use it asks to link the machine in a
30
+ browser. Then choose one of:
31
+
32
+ - **Serve all my sessions** — runs in the current terminal until `Ctrl-C`.
33
+ - **Always-on background service** — starts at login/reboot and restarts after a
34
+ crash. The normal install is pinned to the confirmed package version; use the
35
+ launcher’s **Restart & update bridge** action when a new version should land.
36
+
37
+ Back in the room, open the runtime/model you want for each terminal.
38
+
39
+ ## Account and service commands
40
+
41
+ Link the machine without opening the full launcher:
160
42
 
161
43
  ```bash
162
- npx thinkpool-pair@latest privacy-report
44
+ npx thinkpool-pair@latest login
45
+ ```
46
+
47
+ Install or remove the account-wide background service:
48
+
49
+ ```bash
50
+ npx thinkpool-pair@latest install-service
51
+ npx thinkpool-pair@latest restart-service
52
+ npx thinkpool-pair@latest uninstall-service
53
+ ```
54
+
55
+ `install-service` is pinned by default so a later bad npm publish cannot
56
+ silently replace a working unattended bridge. Opt into tracking `@latest` only
57
+ when that trade-off is intentional:
58
+
59
+ ```bash
60
+ npx thinkpool-pair@latest install-service --auto-update
163
61
  ```
164
62
 
165
- It lists the project directories configured for this bridge, the local records
166
- present under `~/.thinkpool-pair`, known outbound service domains, what
167
- Thinkpool stores remotely, and the limits of the report. It never prints a
168
- provider key, refresh token, or bridge private key.
63
+ Service implementation by platform:
169
64
 
170
- ## Run any model
65
+ - macOS: LaunchAgent
66
+ - Linux: systemd user service; run `loginctl enable-linger "$USER"` once if it
67
+ must survive logout
68
+ - Windows: Startup-folder command file
171
69
 
172
- ThinkPool Code runs **any model you choose** — not just Anthropic. The agent
173
- talks the Anthropic Messages API, so you point it at any endpoint that speaks
174
- that format:
70
+ On Windows the startup entry launches at login; update it from the launcher and
71
+ relaunch the bridge window to apply the new version.
175
72
 
176
- - **Anthropic-compatible endpoints directly** — Z.ai GLM, Moonshot Kimi, OpenRouter, your own proxy.
177
- - **Any other model via a translating gateway** — put **LiteLLM** (or a proxy of
178
- your own) in front and run **GPT, Gemini, Llama, DeepSeek, or a local model**;
179
- it presents the Anthropic Messages API while calling whatever you pick.
73
+ ## Project directories
180
74
 
181
- > OpenRouter is the exception worth knowing: its `/v1/messages` surface is a
182
- > pass-through for **Anthropic models only**. Per OpenRouter's own docs, "Claude
183
- > Code expects Anthropic request semantics, so non-Anthropic models aren't
184
- > supported through the native endpoint" — and this bridge spawns the `claude`
185
- > CLI. To reach a non-Anthropic model, use a translating gateway (LiteLLM),
186
- > not OpenRouter's Anthropic endpoint.
75
+ The account bridge serves sessions from their bound project directories. Set a
76
+ default for new/unbound sessions:
187
77
 
188
78
  ```bash
189
- npx thinkpool-pair@latest provider custom --base <url> --token <key> [--model <name>]
79
+ npx thinkpool-pair@latest set-default-dir /path/to/project
190
80
  ```
191
81
 
192
- Common base urls (the agent appends `/v1/messages`):
82
+ Bind one room explicitly when needed:
193
83
 
194
- | Provider / gateway | Base url | Models |
195
- |--------------------|-----------------------------------|------------------------------|
196
- | Z.ai GLM | `https://api.z.ai/api/anthropic` | GLM |
197
- | Moonshot Kimi | `https://api.moonshot.ai/anthropic` | Kimi |
198
- | OpenRouter | `https://openrouter.ai/api` | Anthropic models only |
199
- | LiteLLM (your own) | `http://localhost:4000` | GPT, Gemini, Llama, local, … |
84
+ ```bash
85
+ npx thinkpool-pair@latest bind <ROOM> /path/to/project
86
+ ```
87
+
88
+ Room codes are treated as secrets because room members can drive agents that
89
+ have access to the bound project and the host permissions granted to that lane.
200
90
 
201
- > The endpoint must serve the Anthropic Messages API (`/v1/messages`). A raw
202
- > OpenAI-style `/v1/chat/completions` won't work directly — that's exactly what a
203
- > gateway is for. Avoid LiteLLM 1.82.7 / 1.82.8 (compromised releases); use a
204
- > current version.
91
+ ## Runtimes and providers
205
92
 
206
- Show the current provider (`npx thinkpool-pair@latest provider`), or reset back to your
207
- regular Claude login:
93
+ | Runtime | Authentication/provider behavior |
94
+ |---|---|
95
+ | Claude | Uses the host’s normal Anthropic login by default. A registered Anthropic-compatible provider can be selected for a lane. |
96
+ | Codex | Uses the host’s Codex/OpenAI login. Custom bridge providers are not wired to Codex lanes. |
97
+ | Hermes | Uses an isolated Thinkpool ACP profile. Set it up explicitly before the first Hermes lane. |
98
+
99
+ The launcher’s **Settings → Provider** flow configures the host default for
100
+ Claude-compatible lanes. The equivalent CLI commands are:
208
101
 
209
102
  ```bash
103
+ npx thinkpool-pair@latest provider
210
104
  npx thinkpool-pair@latest provider anthropic
105
+ npx thinkpool-pair@latest provider custom --base <url> --token <key> --model <id>
211
106
  ```
212
107
 
213
- Provider config is stored in `~/.thinkpool-pair/provider.json` (mode 0600) and
214
- applies to every bridge on the machine. Restart the bridge (or its launchd
215
- service) after changing it — the choice is read once at startup.
108
+ The custom endpoint must implement the Anthropic Messages API. A raw
109
+ OpenAI-compatible chat-completions endpoint is not enough; place a translating
110
+ gateway in front of it or use the runtime that natively owns that provider.
216
111
 
217
- ### Context meter for non-Claude models
112
+ Set up an isolated Hermes profile with one of:
218
113
 
219
- The context-usage meter (`ctx N%` in the room) is computed by the Agent SDK from
220
- the model id it thinks it's talking to — for a non-Claude model bridged in over
221
- an Anthropic-compatible endpoint, that assumed window is wrong, so the meter can
222
- run past 100%. The bridge corrects it for common models automatically (GLM-4.5/4.6,
223
- DeepSeek V3.x, Kimi K2, Qwen3-Coder — matched by model id).
114
+ ```bash
115
+ npx thinkpool-pair@latest setup-hermes --clone
116
+ npx thinkpool-pair@latest setup-hermes --clean
117
+ ```
118
+
119
+ `--clone` copies the active Hermes provider/configuration into the isolated
120
+ profile. `--clean` creates the isolation boundary without copying credentials.
121
+
122
+ ## Direct single-room mode
123
+
124
+ The account-wide launcher is the normal path. A direct room process is still
125
+ available for debugging or a deliberately single-room host:
126
+
127
+ ```bash
128
+ npx thinkpool-pair@latest <ROOM> -- claude
129
+ npx thinkpool-pair@latest <ROOM> -- codex
130
+ npx thinkpool-pair@latest <ROOM> -- hermes
131
+ ```
224
132
 
225
- For any model not in that list, set the real window explicitly per bridge:
133
+ Add `--headless` when the process should be driven only from the room. Direct
134
+ room services use the same command with the room code:
226
135
 
227
136
  ```bash
228
- export TP_CONTEXT_MAX=131072 # your model's context window, in tokens
137
+ npx thinkpool-pair@latest install-service <ROOM> -- claude
138
+ npx thinkpool-pair@latest uninstall-service <ROOM>
229
139
  ```
230
140
 
231
- `TP_CONTEXT_MAX` overrides the built-in map (and applies even to Claude ids if
232
- you set it). Unset it to fall back to the automatic correction.
141
+ ## Cloud or always-on host
142
+
143
+ The bridge can run on a VM, dev box, home server, or container because its room
144
+ connection is outbound-only. Use the same launcher/service commands and provide
145
+ the runtime’s headless credentials through the host’s secret manager.
146
+
147
+ For Claude, either an Anthropic API key or a valid Claude Code OAuth token can
148
+ authenticate the runtime. Treat subscription/OAuth tokens as personal secrets;
149
+ do not hand them to a third-party host.
150
+
151
+ ## What crosses the boundary
152
+
153
+ The bridge and web app deliberately split responsibility:
154
+
155
+ - Agent processes, repository access, provider credentials, and raw terminal
156
+ bytes remain on the bridge machine.
157
+ - Thinkpool relays room events and terminal output to authorized room members.
158
+ - Thinkpool stores the cleaned reader transcript and room events so members can
159
+ reopen the room.
160
+ - Thinkpool does not store a copy of the repository or a second copy of the raw
161
+ PTY byte stream.
162
+ - Voice dictation is an application API path and uses the configured
163
+ transcription service; it is not a local-only bridge operation.
164
+
165
+ Inspect the host-side boundary before pairing a sensitive repository:
166
+
167
+ ```bash
168
+ npx thinkpool-pair@latest privacy-report
169
+ ```
170
+
171
+ The report lists configured project directories, local bridge records, known
172
+ outbound domains, remote storage categories, and the report’s limits. It does
173
+ not print provider keys, refresh tokens, or bridge private keys.
174
+
175
+ Structured session state is stored below `~/.thinkpool-pair/` so an agent lane
176
+ can resume after a bridge restart. Protect that directory like other local
177
+ developer credentials and session state.
178
+
179
+ ## Visual verification tools
180
+
181
+ Structured Claude and Codex lanes can use bridge-owned preview tools even when
182
+ their own sandbox cannot bind a port or launch a browser:
183
+
184
+ - `preview_start` serves a built directory inside the lane worktree.
185
+ - `preview_capture` records exact desktop and mobile renders.
186
+ - `preview_inspect` reads rendered DOM text and geometry.
187
+ - `preview_stop` releases the preview server.
188
+
189
+ Preview roots are read-only, cannot escape the lane worktree, and accept only
190
+ the preview server’s loopback origin for page requests.
191
+
192
+ ## Optional bridge environment variables
193
+
194
+ | Variable | Purpose |
195
+ |---|---|
196
+ | `TP_NAME` | Label this bridge in the room |
197
+ | `TP_PAIR_ROOT` | Override the local state directory |
198
+ | `TP_SUPABASE_URL` / `TP_SUPABASE_ANON` | Override the built-in Thinkpool project endpoint/public key |
199
+ | `TP_ANTHROPIC_BASE_URL` / `TP_ANTHROPIC_AUTH_TOKEN` / `TP_ANTHROPIC_MODEL` | Override the Claude-compatible provider without writing provider config |
200
+ | `TP_CONTEXT_MAX` | Override the context-window size used by the room meter |
201
+ | `TP_BROWSER_PATH` | Point preview capture at a non-standard Chrome/Chromium binary |
202
+ | `TP_FLOW_OFF=1` | Disable Flow dispatch on this bridge |
203
+ | `TP_SPAWN_OFF=1` | Disable agent-spawned worker lanes |
204
+ | `TP_PAIRBUS_OFF=1` | Disable the paired-room bus |
205
+ | `TP_CROSSROOM_OFF=1` | Disable cross-session reach |
206
+
207
+ Provider and account configuration written by the launcher is preferred for a
208
+ managed service because shell startup files are not guaranteed to be sourced.
209
+
210
+ ## Troubleshooting
211
+
212
+ ### The room cannot see the bridge
213
+
214
+ Run the launcher and check its account/runtime/service header. If the account
215
+ link is missing or expired, rerun:
216
+
217
+ ```bash
218
+ npx thinkpool-pair@latest login
219
+ ```
220
+
221
+ ### A service is installed but not serving
222
+
223
+ Use the launcher’s restart/update action or:
224
+
225
+ ```bash
226
+ npx thinkpool-pair@latest restart-service
227
+ ```
228
+
229
+ The install command prints the platform-specific log directory. Inspect that
230
+ log before reinstalling; repeated installation can hide the original failure.
231
+
232
+ ### No runtimes are available
233
+
234
+ Install/sign in to Claude, Codex, or Hermes on the host, then restart the
235
+ launcher. Runtime availability is detected from the host; the web room cannot
236
+ install a missing CLI for you.
237
+
238
+ ### A custom model fails immediately
239
+
240
+ Check that the endpoint implements Anthropic Messages semantics and that the
241
+ model ID exists on that provider. Codex lanes ignore the bridge’s custom Claude
242
+ provider configuration and use the Codex/OpenAI login.
243
+
244
+ ### Preview capture cannot find Chrome
245
+
246
+ Install Chrome/Chromium on the bridge host or set `TP_BROWSER_PATH` to its
247
+ executable.
248
+
249
+ ## Develop the bridge package
250
+
251
+ From the repository root:
252
+
253
+ ```bash
254
+ npm --prefix bridge ci
255
+ npm run test:bridge
256
+ node bridge/test-packed-artifact.mjs
257
+ ```
258
+
259
+ Run the checked-out launcher with:
260
+
261
+ ```bash
262
+ node bridge/bridge.mjs
263
+ ```
264
+
265
+ Publishing is separate from the web-app deploy:
266
+
267
+ ```bash
268
+ npm --prefix bridge run release
269
+ ```
233
270
 
234
- Public anon creds are embedded (the same ones the web app ships). Override with
235
- `TP_SUPABASE_URL` / `TP_SUPABASE_ANON` if needed. Set `TP_NAME` to label yourself.
271
+ The release command has external side effects and requires npm publish
272
+ authority. Do not use it as a local verification command.
package/account.mjs CHANGED
@@ -25,6 +25,7 @@ import { hostMemoryAdmission } from './host-memory.mjs'
25
25
  import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
26
26
  import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
27
27
  import { PAIR_CLI, pairCli } from './command-guidance.mjs'
28
+ import { syncRealtimeAuth } from './design-edit.mjs'
28
29
 
29
30
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
30
31
 
@@ -515,7 +516,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
515
516
  let lastClaimHeld = null // track state changes for claim_tick logging
516
517
  console.log(`◇ supervisor boot sup=${SUP_ID} host=${machine} pid=${process.pid} npx=${VERSION || 'dev'}`)
517
518
 
519
+ if (!await syncRealtimeAuth(sb, currentAccessToken, SUPABASE_ANON)) {
520
+ process.stderr.write('\n ⚠ realtime auth could not be initialized; private bridge control is unavailable.\n')
521
+ }
518
522
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
523
+ const acctControl = sb.channel(`tpacct-control:${session.user.id}`, { config: { private: true, broadcast: { self: false } } })
519
524
  // One id per account-bridge PROCESS. The dashboard keeps its restart indicator
520
525
  // up until this value changes, so stale presence + fresh claim heartbeats can
521
526
  // never impersonate a completed restart.
@@ -523,8 +528,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
523
528
  let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
524
529
  // Web "attach this session" card → bind an unbound/new room to a local dir (and
525
530
  // optionally set it as the account default) so it serves WITHOUT a terminal command.
526
- // Rides tpacct (keyed to the owner's uid); the dir must exist on THIS machine.
527
- acct.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
531
+ // Rides the private, owner-gated control topic; the dir must exist on THIS machine.
532
+ acctControl.on('broadcast', { event: 'bind-room' }, ({ payload }) => {
528
533
  const code = String(payload?.code || '').toUpperCase().trim()
529
534
  let dir = String(payload?.dir || '').trim()
530
535
  if (dir.startsWith('~')) dir = path.join(os.homedir(), dir.slice(1))
@@ -541,7 +546,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
541
546
  // so the toggle would appear to do nothing (presence leaves, then rejoins).
542
547
  // Uninstall the account service FIRST so the supervisor won't restart us, then
543
548
  // stop. Mirrors the per-room session-deleted guard in bridge.mjs.
544
- acct.on('broadcast', { event: 'shutdown' }, async () => {
549
+ acctControl.on('broadcast', { event: 'shutdown' }, async () => {
545
550
  process.stderr.write('\n ◇ disconnect requested from the dashboard — stopping bridge.\n')
546
551
  // Hard-exit backstop, armed FIRST — independent of the SIGTERM round-trip,
547
552
  // the realtime socket, and every await below. If the graceful stop() wedges
@@ -565,14 +570,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
565
570
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
566
571
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
567
572
  let restartPreparationRunning = false
568
- acct.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
573
+ acctControl.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
569
574
  const nonce = payload?.nonce
570
575
  const reply = async (ok, error) => {
571
- try { await acct.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
572
- }
573
- if (!(await isOwner(payload?.jwt))) {
574
- await reply(false, 'unauthorized')
575
- return
576
+ try { await acctControl.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
576
577
  }
577
578
  // A dashboard restart is an EXPLICIT apply request, but it is not permission
578
579
  // to interrupt a live turn or a pending human decision. Stage the immutable
@@ -636,22 +637,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
636
637
  }
637
638
 
638
639
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
639
- // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
640
- // so it is a PUBLIC realtime channel — NOT RLS-gated (the existing shutdown/restart
641
- // handlers ride the same public channel; accepted for those, NOT for key management).
642
- // Every provider event therefore carries the sender's `jwt` (session access token);
643
- // we verify getUser(jwt).id === the owner uid before ANY registry mutation or read.
644
- // Only the owner manages their own keys on their own machine — a partner is refused.
645
- const OWNER_UID = session.user.id
646
- const isOwner = async (jwt) => {
647
- if (!jwt || typeof jwt !== 'string') return false
648
- try { const { data } = await sb.auth.getUser(jwt); return !!data?.user && data.user.id === OWNER_UID } catch { return false }
649
- }
650
- const provReply = (event, payload) => { try { acct.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
651
- // provider-add {sealed, nonce, jwt} → decrypt, validate, append, re-announce.
652
- acct.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
640
+ // The private tpacct-control:<uid> join/send policy binds the sender to this
641
+ // exact owner. Credentials stay in the Realtime handshake, never the payload.
642
+ const provReply = (event, payload) => { try { acctControl.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
643
+ // provider-add {sealed, nonce} → decrypt, validate, append, re-announce.
644
+ acctControl.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
653
645
  const nonce = payload?.nonce
654
- if (!(await isOwner(payload?.jwt))) return provReply('provider-add-res', { nonce, ok: false, error: 'unauthorized' })
655
646
  let fields
656
647
  // Decrypt failures answer ok:false WITHOUT logging the ciphertext or plaintext
657
648
  // (security invariant d) — a bare boolean, never the sealed blob or the key.
@@ -660,29 +651,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
660
651
  if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
661
652
  provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
662
653
  })
663
- // provider-add-model {id, model, name?, nonce, jwt} → clone the source row's
654
+ // provider-add-model {id, model, name?, nonce} → clone the source row's
664
655
  // baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
665
656
  // `sealed` envelope: the key never leaves the bridge for this op, so there is
666
657
  // nothing to decrypt. Owner-authed exactly like its siblings.
667
- acct.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
658
+ acctControl.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
668
659
  const nonce = payload?.nonce
669
- if (!(await isOwner(payload?.jwt))) return provReply('provider-add-model-res', { nonce, ok: false, error: 'unauthorized' })
670
660
  const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
671
661
  if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
672
662
  provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
673
663
  })
674
- // provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
675
- acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
664
+ // provider-remove {id, nonce} → remove (built-in refuses), re-announce.
665
+ acctControl.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
676
666
  const nonce = payload?.nonce
677
- if (!(await isOwner(payload?.jwt))) return provReply('provider-remove-res', { nonce, ok: false, error: 'unauthorized' })
678
667
  const r = removeProvider(payload?.id)
679
668
  if (r.ok) { pushPresence(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
680
669
  provReply('provider-remove-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error })
681
670
  })
682
- // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
683
- acct.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
671
+ // providers-list-req {nonce} → the masked list (keyHint = last 4 chars only).
672
+ acctControl.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
684
673
  const nonce = payload?.nonce
685
- if (!(await isOwner(payload?.jwt))) return provReply('providers-list-res', { nonce, ok: false, error: 'unauthorized', providers: [] })
686
674
  provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
687
675
  })
688
676
 
@@ -735,6 +723,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
735
723
  if (!initialAccountRealtime.connected) {
736
724
  process.stderr.write(`\n ◇ account realtime not ready at startup (${initialAccountRealtime.status}) — staying alive and recovering in process.\n`)
737
725
  }
726
+ const initialAccountControl = await subscribeWithoutStartupDeadlock(acctControl, (st) => {
727
+ if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
728
+ process.stderr.write(`\n ⚠ private account control ${st}; host-changing dashboard actions are unavailable until Realtime reconnects.\n`)
729
+ }
730
+ })
731
+ if (!initialAccountControl.connected) {
732
+ process.stderr.write(`\n ◇ private account control not ready at startup (${initialAccountControl.status}) — presence remains available.\n`)
733
+ }
738
734
 
739
735
  // Keep the account JWT fresh so the presence socket never gets deauthed at
740
736
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -1275,7 +1271,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1275
1271
  // until it lapses), never lost data. No caller remains to surface an error to, and a
1276
1272
  // throw here would skip process.exit.
1277
1273
  // fail-open-ok: exit-path teardown; a lost release_bridge expires on its own TTL
1278
- ;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
1274
+ ;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acctControl) } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
1279
1275
  setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
1280
1276
  }
1281
1277
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
package/bridge.mjs CHANGED
@@ -47,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, shouldRecordTurnDone, permiss
47
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
48
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
49
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
50
- import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
+ import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
51
51
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
52
52
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
53
53
  import { z } from 'zod'
@@ -764,30 +764,6 @@ function reconcileTerminalRows() {
764
764
  if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
765
765
  })
766
766
  }
767
- // Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
768
- // serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
769
- // Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
770
- // (code=room, participants cs.{uid}) — NOT byte comparison of the jwt against codeAuthToken
771
- // (that bug already happened once tonight: a freshly-refreshed jwt has a new signature/iat/jti
772
- // per the grant cycle, so a byte compare rejects the legitimate sender). Hoisted to module
773
- // scope so provider-switch + providers-list-req share the ONE implementation. Returns false
774
- // for anon / missing / invalid jwt without throwing. Fails closed on any fetch error.
775
- const isRoomParticipant = async (jwt) => {
776
- if (!jwt || typeof jwt !== 'string') return false
777
- try {
778
- const { data } = await supabase.auth.getUser(jwt)
779
- if (!data?.user) return false
780
- const uid = data.user.id
781
- // Query with the CALLER's jwt (not the bridge host's codeAuthToken) so RLS scopes
782
- // the read to what THIS user may see, and require an actual matching row — `r.ok`
783
- // alone is true for an empty [] result, which let ANY authenticated user pass.
784
- // participants is a uuid[]; PostgREST array-contains is cs.{uid} (braces required).
785
- const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.%7B${encodeURIComponent(uid)}%7D&select=code`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${jwt}` } })
786
- if (!r.ok) return false
787
- const rows = await r.json().catch(() => [])
788
- return Array.isArray(rows) && rows.length > 0
789
- } catch { return false }
790
- }
791
767
  // ── Room-serve owner consent (Contract C-CODE-3, owner-only since 2026-07-06) ──
792
768
  // A room is served by its OWNER's bridge ONLY — no cross-person grantee path
793
769
  // (removed 2026-07-06; see docs/specs/2026-07-06-remove-cross-person-serve.md).
@@ -983,6 +959,12 @@ const designChannel = supabase.channel(`tpdesign:${room}`, {
983
959
  config: { private: true, broadcast: { self: false } },
984
960
  })
985
961
 
962
+ // Host-changing room commands are isolated from the public collaboration topic.
963
+ // Realtime RLS admits current room members and authenticates every send/receive.
964
+ const controlChannel = supabase.channel(`tpcontrol:${room}`, {
965
+ config: { private: true, broadcast: { self: false } },
966
+ })
967
+
986
968
  // ── terminal registry ──────────────────────────────────────────────
987
969
  // id → { term (pty), cmd, attached, scrollback, buf }
988
970
  const terms = new Map()
@@ -3884,6 +3866,85 @@ process.stdout.on('resize', () => {
3884
3866
  let realtimeHealthy = false
3885
3867
  let brokenSince = Date.now()
3886
3868
 
3869
+ // ── Private room control plane ──────────────────────────────────────────────
3870
+ // Authentication and membership are enforced by realtime.messages RLS when
3871
+ // tpcontrol:<room> joins and broadcasts. Access tokens never enter payloads.
3872
+ controlChannel
3873
+ .on('broadcast', { event: 'apply-update' }, () => {
3874
+ if (!pendingUpdate) return
3875
+ applyRequested = true
3876
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
3877
+ try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
3878
+ }
3879
+ })
3880
+ .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
3881
+ const nonce = payload?.nonce
3882
+ const reply = (ok, error, state) => controlChannel.send({
3883
+ type: 'broadcast', event: 'bridge-update-res',
3884
+ payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
3885
+ })
3886
+ if (!nonce) return reply(false, 'invalid request')
3887
+ const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
3888
+ ? payload.v : null
3889
+ if (!target) return reply(false, 'invalid bridge version')
3890
+
3891
+ surfaceUpdate(target)
3892
+ applyRequested = true
3893
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
3894
+ try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
3895
+ return reply(true, null, 'queued')
3896
+ }
3897
+
3898
+ let managed = false
3899
+ try {
3900
+ const svc = await import('./service.mjs')
3901
+ managed = svc.serviceActive(room)
3902
+ } catch { /* reported as an explicit foreground refusal below */ }
3903
+ if (!managed) {
3904
+ applyRequested = false
3905
+ return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
3906
+ }
3907
+ if (!betweenUpdateTurns()) return reply(true, null, 'queued')
3908
+ await reply(true, null, 'applying')
3909
+ void applyStandaloneManagedUpdate()
3910
+ })
3911
+ .on('broadcast', { event: 'providers-list-req' }, ({ payload }) => {
3912
+ controlChannel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce: payload?.nonce, ok: true, providers: listProviders() } })
3913
+ })
3914
+ .on('broadcast', { event: 'provider-switch' }, ({ payload }) => {
3915
+ const nonce = payload?.nonce
3916
+ const reply = (ok, error, restarted) => controlChannel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
3917
+ const term = payload?.term
3918
+ const s = term ? sessions.get(term) : null
3919
+ if (!s) return reply(false, 'no such live lane')
3920
+ const v = validateProviderSwitch({
3921
+ provider: payload?.provider,
3922
+ currentProvider: s.provider,
3923
+ registeredIds: listProviders().map((p) => p.id),
3924
+ })
3925
+ if (!v.ok) return reply(false, v.error)
3926
+ const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
3927
+ const plan = providerSwitchPlan({
3928
+ provider: payload?.provider,
3929
+ currentProvider: s.provider,
3930
+ sameEnv: sameProviderEnv(s.provider, target),
3931
+ targetModel: providerModel(target),
3932
+ })
3933
+ if (plan.action === 'noop') return reply(true, undefined, false)
3934
+ if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
3935
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
3936
+ announce()
3937
+ return reply(true, undefined, false)
3938
+ }
3939
+ const done = respawnStructured(term, target)
3940
+ if (!done) return reply(false, 'no such live lane')
3941
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
3942
+ reply(true, undefined, true)
3943
+ })
3944
+ .subscribe((status) => {
3945
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ private bridge control ${status} (tpcontrol:${room}).\n`)
3946
+ })
3947
+
3887
3948
  channel
3888
3949
  .on('broadcast', { event: 'pty-in' }, ({ payload }) => {
3889
3950
  if (!payload?.data) return
@@ -4532,54 +4593,6 @@ channel
4532
4593
  .on('broadcast', { event: 'code-close' }, ({ payload }) => {
4533
4594
  endStructured(payload?.id)
4534
4595
  })
4535
- // Slice 3 — the user clicked "apply" on the update chip. Mark it requested; the
4536
- // actual restart is gated to between turns. Account-child: forward to the
4537
- // supervisor (it owns the restart, applied only when every child is idle).
4538
- // Standalone service tier: its applyIfIdle loop lands it at the next idle. Either
4539
- // way it never interrupts a turn (Contract #1).
4540
- .on('broadcast', { event: 'apply-update' }, () => {
4541
- if (!pendingUpdate) return
4542
- applyRequested = true
4543
- if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4544
- try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
4545
- }
4546
- })
4547
- // Authenticated + acknowledged update contract used by both the room card and
4548
- // the dashboard's room-scoped fallback. Unlike the legacy apply-update event,
4549
- // this does not depend on the bridge having discovered npm first: the web names
4550
- // the advertised target, the host updater independently resolves npm latest,
4551
- // and only a managed service is allowed to accept the operation.
4552
- .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
4553
- const nonce = payload?.nonce
4554
- const reply = (ok, error, state) => channel.send({
4555
- type: 'broadcast', event: 'bridge-update-res',
4556
- payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
4557
- })
4558
- if (!nonce || !(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4559
- const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
4560
- ? payload.v : null
4561
- if (!target) return reply(false, 'invalid bridge version')
4562
-
4563
- surfaceUpdate(target)
4564
- applyRequested = true
4565
- if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4566
- try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
4567
- return reply(true, null, 'queued')
4568
- }
4569
-
4570
- let managed = false
4571
- try {
4572
- const svc = await import('./service.mjs')
4573
- managed = svc.serviceActive(room)
4574
- } catch { /* reported as an explicit foreground refusal below */ }
4575
- if (!managed) {
4576
- applyRequested = false
4577
- return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
4578
- }
4579
- if (!betweenUpdateTurns()) return reply(true, null, 'queued')
4580
- await reply(true, null, 'applying')
4581
- void applyStandaloneManagedUpdate()
4582
- })
4583
4596
  // Persist + re-announce terminal renames. The web also echoes term-rename to
4584
4597
  // online peers directly; storing it here is what reaches a device that joins
4585
4598
  // LATER (or a second machine) — those only ever see the announce.
@@ -4593,111 +4606,6 @@ channel
4593
4606
  announce()
4594
4607
  })
4595
4608
  .on('broadcast', { event: 'who' }, announce)
4596
- // ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
4597
- // AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
4598
- // the sender is a participant of THIS room before ANY operation. List is open to
4599
- // any participant; add/remove mutate the HOST's registry, so gate to the BRIDGE
4600
- // HOST's own uid (the authed login). Anon is refused for all three.
4601
- .on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
4602
- const nonce = payload?.nonce
4603
- // Auth: must be the bridge host (the authed login running this bridge)
4604
- // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
4605
- try {
4606
- const { data } = await supabase.auth.getUser(payload?.jwt)
4607
- if (!data?.user || data.user.id !== myServeUid) {
4608
- return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4609
- }
4610
- } catch {
4611
- return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4612
- }
4613
- let fields
4614
- try { fields = unseal(payload?.sealed) } catch { return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'could not decrypt the sealed payload' } }) }
4615
- const r = addProvider(fields)
4616
- if (r.ok) { announce(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
4617
- channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined } })
4618
- })
4619
- // provider-remove {id, nonce, jwt} → remove, re-announce.
4620
- .on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
4621
- const nonce = payload?.nonce
4622
- // Auth: must be the bridge host (the authed login running this bridge)
4623
- // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
4624
- try {
4625
- const { data } = await supabase.auth.getUser(payload?.jwt)
4626
- if (!data?.user || data.user.id !== myServeUid) {
4627
- return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4628
- }
4629
- } catch {
4630
- return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
4631
- }
4632
- const r = removeProvider(payload?.id)
4633
- if (r.ok) { announce(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
4634
- channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
4635
- })
4636
- // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
4637
- // Auth via the shared isRoomParticipant (hoisted; same pattern as provider-switch).
4638
- .on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
4639
- const nonce = payload?.nonce
4640
- if (!(await isRoomParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
4641
- channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
4642
- })
4643
- // ── Per-lane provider SWITCH (multi-BYOK slice 2). Switch a LIVE structured lane to a
4644
- // different LLM provider by restarting that lane's agent process under the SAME terminal
4645
- // id with the new provider's env. Visible transcript persists; the agent's conversational
4646
- // memory resets (cross-backend, no SDK resume) — accepted UX the UI warns about. Same-
4647
- // provider model changes keep using the EXISTING /model path (in-process setModel); the UI
4648
- // only sends provider-switch when the provider ACTUALLY changes.
4649
- // provider-switch {term, provider, nonce, jwt} → bridge validates + respawns
4650
- // provider-switch-res {nonce, ok, error?} ← reply (NEVER key material)
4651
- // Auth: participant check via supabase.auth.getUser (isRoomParticipant) — NOT byte
4652
- // comparison of the jwt (a refreshed jwt has a new signature/iat/jti). Anon refused.
4653
- // After the respawn the lane's next announce reflects its new provider (additive {id,name}
4654
- // projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
4655
- .on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
4656
- const nonce = payload?.nonce
4657
- // `restarted` (0.7.179+): did the agent lose its context? false for a same-key model
4658
- // change, true for a real provider change. Older clients ignore the extra field.
4659
- const reply = (ok, error, restarted) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
4660
- // AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
4661
- // state is touched for a rejected sender (fail closed before any lookup).
4662
- if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4663
- const term = payload?.term
4664
- const s = term ? sessions.get(term) : null
4665
- // The target must be a LIVE structured session this bridge serves.
4666
- if (!s) return reply(false, 'no such live lane')
4667
- // VALIDATE — pure: missing / unknown provider, or a no-op same-provider switch.
4668
- const v = validateProviderSwitch({
4669
- provider: payload?.provider,
4670
- currentProvider: s.provider,
4671
- registeredIds: listProviders().map((p) => p.id),
4672
- })
4673
- if (!v.ok) return reply(false, v.error)
4674
- const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
4675
-
4676
- // The three-way choice is pure + unit-tested (switch-provider.mjs). Two registry rows
4677
- // on ONE endpoint + key (Max's glm-4.6 and glm-5.2 on the same z.ai account) are the
4678
- // same provider asked for a different model: the child's env is identical, so tearing
4679
- // the agent down and recapping it is pure loss. Only a different endpoint/key earns a
4680
- // respawn. An in-place attempt that fails (lane raced closed, setModel threw) falls
4681
- // back to the respawn rather than leaving the lane on a stale model.
4682
- const plan = providerSwitchPlan({
4683
- provider: payload?.provider,
4684
- currentProvider: s.provider,
4685
- sameEnv: sameProviderEnv(s.provider, target),
4686
- targetModel: providerModel(target),
4687
- })
4688
- if (plan.action === 'noop') return reply(true, undefined, false) // idempotent (UI guarantees a change)
4689
-
4690
- if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
4691
- process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
4692
- announce()
4693
- return reply(true, undefined, false)
4694
- }
4695
-
4696
- const done = respawnStructured(term, target)
4697
- if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
4698
- process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
4699
- reply(true, undefined, true)
4700
- })
4701
4609
  .subscribe(async status => {
4702
4610
  if (status === 'SUBSCRIBED') {
4703
4611
  realtimeHealthy = true; brokenSince = 0
@@ -5395,6 +5303,7 @@ async function shutdown(code = 0, farewell = true) {
5395
5303
  // process teardown). Close it too so shutdown is symmetric.
5396
5304
  try { await supabase.removeChannel(flowChannel) } catch { /* noop */ }
5397
5305
  try { await supabase.removeChannel(designChannel) } catch { /* noop */ }
5306
+ try { await supabase.removeChannel(controlChannel) } catch { /* noop */ }
5398
5307
  setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
5399
5308
  }
5400
5309
  process.on('SIGINT', () => shutdown(0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.326",
3
+ "version": "0.7.328",
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": {
package/terminal-name.mjs CHANGED
@@ -7,7 +7,8 @@ const SKIP = new Set([
7
7
  'do', 'much', 'better', 'really', 'need', 'want', 'your', 'their', 'its',
8
8
  'also', 'all', 'away', 'before', 'cause', 'does', 'dont', "don't", 'fully', 'has',
9
9
  'have', 'now', 'occasionally', 'return', 'second', 'so', 'sometimes', 'still',
10
- 'than', 'then', 'there', 'whenever', 'why', 'working',
10
+ 'than', 'then', 'there', 'what', 'when', 'where', 'which', 'who', 'whenever',
11
+ 'why', 'working',
11
12
  ])
12
13
 
13
14
  const GENERIC = /^(?:new )?(?:agent |coding )?(?:terminal|task|lane|session|work)$/i
@@ -16,6 +17,8 @@ const ANY_HEADING = /^(?:#{1,6}\s+|(?:context|background|constraints?|inputs?|ou
16
17
  const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(?:ly)?|example|environment|room now|constraints?|acceptance|success criteria)\b/i
17
18
  const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
18
19
  const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
20
+ const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
21
+ const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
19
22
  const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
20
23
  const REQUEST = /^(?:please\s+)?(?:can|could|would|will)\s+(?:we|you)\b|^(?:please\s+)?(?:how about|let's|let us|we need to|i want you to)\b/i
21
24
  const SECRET = /(?<![\p{L}\p{N}])(?:sk-(?:proj-)?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|xox[baprs]-[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|glpat-[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|AIza[a-z0-9_-]{8,}|AKIA[A-Z0-9]{12,}|bearer\s+[a-z0-9._-]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,})(?![\p{L}\p{N}])/giu
@@ -125,12 +128,14 @@ const bestIntentClause = (text) => {
125
128
  const action = bestAction(clause)
126
129
  let score = action?.score || 0
127
130
  if (REQUEST.test(clause)) score += 18
131
+ if (INFORMATION_REQUEST.test(clause)) score += 36
128
132
  if (ISSUE.test(clause)) score += /\b(?:crash|fail|flash|stuck|wrong)\w*\b/i.test(clause) ? 36 : 26
129
133
  if (words.length >= 3 && words.length <= 24) score += 10
130
134
  else if (words.length > 40) score -= 14
131
135
  if (NOISE.test(clause)) score -= 30
132
136
  if (EXPLANATION.test(clause)) score -= 34
133
137
  if (CONSTRAINT.test(clause)) score -= 45
138
+ if (NEGATIVE_PREFERENCE.test(clause)) score -= 70
134
139
  score += Math.round((i / Math.max(1, clauses.length - 1)) * 4)
135
140
  if (!best || score > best.score) best = { clause, score }
136
141
  }
@@ -163,7 +168,7 @@ export function cleanTerminalName(value) {
163
168
 
164
169
  const titleWord = (word) => {
165
170
  const lower = word.toLowerCase()
166
- if (lower === 'thinkpool') return 'ThinkPool'
171
+ if (lower === 'thinkpool' || lower === 'thinkpool.io') return 'ThinkPool'
167
172
  if (lower === 'serverside') return 'Server-Side'
168
173
  if (/^(?:api|ci|css|db|html|llm|mcp|npm|sdk|ui|ux)$/i.test(word)) return lower.toUpperCase()
169
174
  if (/^[A-Z\d+#.-]{2,}$/.test(word) || /\d/.test(word)) return word
@@ -194,6 +199,9 @@ const taskTitle = (value) => {
194
199
  .replace(/^\s*(?:how about|i (?:do not|don't) know|i guess|i want you to|we need to|your task is to|let(?:'s| us))\s+/i, '')
195
200
  .trim()
196
201
  if (!body) return null
202
+ if (NEGATIVE_PREFERENCE.test(body)) return null
203
+ const informationRequest = INFORMATION_REQUEST.test(body)
204
+ if (informationRequest) body = body.replace(/^\s*(?:which|what)\s+/i, '').trim()
197
205
  const signalText = body
198
206
  body = body
199
207
  .replace(/\s*,?\s+(?:so (?:that|you|we|it)\b|because\b|cause\b|if you know what i mean\b)[\s\S]*$/i, '')
@@ -205,7 +213,6 @@ const taskTitle = (value) => {
205
213
  let verb = issue ? 'Fix' : action?.title
206
214
  if (!verb && /\bclos(?:e|es|ed|ing)\b/i.test(body)) verb = 'Close'
207
215
  if (!verb && /\bopen(?:s|ed|ing)?\b/i.test(body)) verb = 'Open'
208
- if (!verb) verb = 'Work On'
209
216
 
210
217
  if (/\bautomatic(?:ally)?\b[\s\S]{0,24}\bnam(?:e|ing)\b/i.test(signalText) && /\bterminals?\b/i.test(signalText)) {
211
218
  if (/\bcontext\b[\s\S]{0,24}\broom\b|\broom\b[\s\S]{0,24}\bcontext\b/i.test(signalText)) return 'Auto-Name Terminals From Room Context'
@@ -226,9 +233,13 @@ const taskTitle = (value) => {
226
233
  }
227
234
  let object = contentWords(objectText)
228
235
  if (!object.length) object = contentWords(body)
236
+ if (informationRequest) {
237
+ const available = object.findIndex((word) => /^available$/i.test(word))
238
+ if (available > 0) object = [object[available], ...object.slice(0, available), ...object.slice(available + 1)]
239
+ }
229
240
  const picked = [...domain, ...object].filter((word, index, all) => all.findIndex((other) => other.toLowerCase() === word.toLowerCase()) === index).slice(0, 4)
230
241
  if (!picked.length) return null
231
- return cleanTerminalName([verb, ...picked.map(titleWord)].join(' '))
242
+ return cleanTerminalName([verb, ...picked.map(titleWord)].filter(Boolean).join(' '))
232
243
  }
233
244
 
234
245
  export function fallbackTerminalName(text) {