thinkpool-pair 0.7.327 → 0.7.330

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.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # bridge/bridge-service.sh — install thinkpool-pair as an AUTO-UPDATING launchd
3
+ # service for a room, with a fast update poll so a new `npm publish` lands in the
4
+ # room within ~1 min of an idle moment (no manual restart, no re-install).
5
+ #
6
+ # The stock `thinkpool-pair install-service <ROOM>` already runs under launchd
7
+ # KeepAlive with `npx -y thinkpool-pair@latest` (so each restart fetches the newest
8
+ # publish) and sets THINKPOOL_PAIR_AUTOUPDATE=1 — but it leaves the poll interval at
9
+ # the 30-min default. This wrapper installs it, then injects the faster poll/idle
10
+ # knobs into the generated LaunchAgent plist and reloads it.
11
+ #
12
+ # Usage: bash bridge/bridge-service.sh <ROOM> [-- <extra bridge args>]
13
+ # e.g. bash bridge/bridge-service.sh P6O6I # default: --headless --auto=claude
14
+ # bash bridge/bridge-service.sh P6O6I -- --headless --auto=claude
15
+ # Env: TP_UPDATE_INTERVAL (default 60) registry poll seconds
16
+ # TP_UPDATE_IDLE (default 20) idle seconds before an update restart
17
+ # TP_FORCE=1 skip the "bare bridge already running" guard
18
+ # Remove: npx thinkpool-pair@latest uninstall-service <ROOM>
19
+ set -euo pipefail
20
+
21
+ ROOM_RAW="${1:-}"
22
+ [ -n "$ROOM_RAW" ] && [ "${ROOM_RAW#-}" = "$ROOM_RAW" ] || { echo "usage: bash bridge/bridge-service.sh <ROOM> [-- <extra bridge args>]"; exit 1; }
23
+ ROOM="$(printf '%s' "$ROOM_RAW" | tr '[:lower:]' '[:upper:]')"
24
+ shift || true
25
+ # Everything after a literal `--` is forwarded to the bridge; default to the
26
+ # headless auto-claude invocation the rooms run interactively today.
27
+ TAIL=()
28
+ if [ "${1:-}" = "--" ]; then shift; TAIL=("$@"); else TAIL=(--headless --auto=claude); fi
29
+
30
+ INTERVAL="${TP_UPDATE_INTERVAL:-60}"
31
+ IDLE="${TP_UPDATE_IDLE:-20}"
32
+ PLIST="$HOME/Library/LaunchAgents/io.thinkpool.pair.$(printf '%s' "$ROOM" | tr '[:upper:]' '[:lower:]').plist"
33
+
34
+ [ "$(uname)" = "Darwin" ] || { echo "✗ this wrapper is macOS/launchd only (Linux: use systemd via 'npx thinkpool-pair@latest install-service')"; exit 1; }
35
+
36
+ # Guard: a bare (foreground) bridge for this room would fight the service —
37
+ # two relays on one realtime channel duplicate everything. Stop it first.
38
+ # NOTE: macOS pgrep is POSIX-ERE — no \b. Match "bridge.mjs <ROOM>" with the room
39
+ # delimited by whitespace or end-of-arg. (A \b guard here once failed silently and
40
+ # serviced the live room, restarting its bridge — don't reintroduce it.)
41
+ BRIDGE_RE="bridge\.mjs[[:space:]]+${ROOM}([[:space:]]|\$)"
42
+ if [ -z "${TP_FORCE:-}" ] && pgrep -f "$BRIDGE_RE" >/dev/null 2>&1; then
43
+ echo "✗ a bare bridge is already running for ${ROOM} (pid $(pgrep -f "$BRIDGE_RE" | tr '\n' ' '))."
44
+ echo " Stop it first (close its terminal / kill it), then re-run — or TP_FORCE=1 to override."
45
+ echo " NOTE: if ${ROOM} is hosting the Claude session you're talking to RIGHT NOW, servicing it restarts the bridge and drops that chat."
46
+ exit 2
47
+ fi
48
+
49
+ echo "◆ installing auto-updating service for ${ROOM} (npx thinkpool-pair@latest ${TAIL[*]})…"
50
+ npx -y thinkpool-pair@latest install-service "$ROOM" -- "${TAIL[@]}"
51
+
52
+ [ -f "$PLIST" ] || { echo "✗ expected plist not found at $PLIST — install may have failed"; exit 3; }
53
+
54
+ # Inject the fast-poll knobs into EnvironmentVariables (Add, or Set if present).
55
+ pb() { /usr/libexec/PlistBuddy -c "$1" "$PLIST" >/dev/null 2>&1; }
56
+ pb "Add :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_INTERVAL string ${INTERVAL}" || pb "Set :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_INTERVAL ${INTERVAL}"
57
+ pb "Add :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_IDLE string ${IDLE}" || pb "Set :EnvironmentVariables:THINKPOOL_PAIR_UPDATE_IDLE ${IDLE}"
58
+
59
+ # Reload so the new env takes effect immediately.
60
+ launchctl unload "$PLIST" 2>/dev/null || true
61
+ launchctl load "$PLIST"
62
+
63
+ echo "✓ ${ROOM} is now a launchd service:"
64
+ echo " • tracks thinkpool-pair@latest, polls npm every ${INTERVAL}s, self-restarts at the next idle ≥${IDLE}s"
65
+ echo " • plist: $PLIST"
66
+ echo " • logs: ~/.thinkpool-pair/${ROOM}.log"
67
+ echo " • remove: npx thinkpool-pair@latest uninstall-service ${ROOM}"
package/bridge.mjs CHANGED
@@ -117,7 +117,7 @@ const flowRedispatch = new Map()
117
117
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
118
118
  // broadcasts; without persistent state the cap can never bite.
119
119
  const flowBudgets = new Map()
120
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
120
+ import { formatPeek, PEEK, readTerminalBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
121
121
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
122
122
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
123
123
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -941,7 +941,7 @@ function recordCodeUsage (model, usage, provider) {
941
941
  let _presence = null
942
942
  const trackPresence = (payload) => { (_presence ||= makeThrottledTrack(channel, { minMs: 5000 }))(payload) }
943
943
  const channel = supabase.channel(`tpcode:${room}`, {
944
- config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
944
+ config: { private: true, broadcast: { self: false }, presence: { key: `bridge:${name}` } },
945
945
  })
946
946
 
947
947
  // Thinkpool Flow rides its OWN topic, separate from the room's primary tpcode channel.
@@ -953,13 +953,13 @@ const flowChannel = supabase.channel(`tpflow:${room}`, {
953
953
  config: { broadcast: { self: false } },
954
954
  })
955
955
 
956
- // Source-changing Design Mode control never rides the public tpcode topic.
956
+ // Source-changing Design Mode control stays isolated from the primary room bus.
957
957
  // Membership RLS on realtime.messages gates join, receive, and send.
958
958
  const designChannel = supabase.channel(`tpdesign:${room}`, {
959
959
  config: { private: true, broadcast: { self: false } },
960
960
  })
961
961
 
962
- // Host-changing room commands are isolated from the public collaboration topic.
962
+ // Host-changing room commands are isolated from the primary collaboration topic.
963
963
  // Realtime RLS admits current room members and authenticates every send/receive.
964
964
  const controlChannel = supabase.channel(`tpcontrol:${room}`, {
965
965
  config: { private: true, broadcast: { self: false } },
@@ -1463,7 +1463,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1463
1463
  if (decision !== 'allow') return { error: `The people in room ${room} declined the incoming task from ${fromLabel}.` }
1464
1464
  // Injected turn: one room-hop deep, fresh in-room budgets (the loop-breaker reset
1465
1465
  // point is code-turn — a real human turn — which this is NOT, so the deeper hop sticks).
1466
- te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1466
+ te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.peekRosterCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1467
1467
  const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
1468
1468
  const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
1469
1469
  autoNameTerminal(targetId, body)
@@ -2581,18 +2581,29 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2581
2581
  ...createViewportTools({ tool, z, manager: entry.viewport }),
2582
2582
  tool(
2583
2583
  'read_terminal',
2584
- '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 every open terminal with busy/idle state, last-action age, and any STUCK/BLOCKED flag; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use the one-read roster to identify a lane that needs attention before opening its transcript.',
2584
+ 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). The current ROOM NOW snapshot is the default roster. Call with no arguments only when that snapshot is missing or truncated; this optional full-roster lookup has its own one-call allowance. Call with `terminal` (a ref, id, name, or command) only when that lane’s detailed activity is needed. Targeted transcript reads have a separate bounded allowance: never poll, collect each finished owned worker once, then close it immediately.',
2585
2585
  {
2586
2586
  terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
2587
2587
  lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
2588
2588
  },
2589
2589
  async (args) => {
2590
- entry.peekCount = (entry.peekCount || 0) + 1
2591
- if (entry.peekCount > PEEK.perTurnCap) {
2592
- 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.` }] }
2593
- }
2594
2590
  const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
2595
2591
  const target = args?.terminal ? resolveSibling(sib, args.terminal) : null
2592
+ // A malformed/stale ref is a no-op, so reject it before spending either
2593
+ // allowance. Discovery and transcript reads are metered independently:
2594
+ // ROOM NOW makes the roster optional, while targeted reads stay available
2595
+ // for actual worker collection and the final review.
2596
+ if (args?.terminal && !target) {
2597
+ return { content: [{ type: 'text', text: formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args.terminal, lines: args?.lines }) }] }
2598
+ }
2599
+ const budget = readTerminalBudgetDecision({
2600
+ targeted: !!args?.terminal,
2601
+ targetedCount: entry.peekCount,
2602
+ rosterCount: entry.peekRosterCount,
2603
+ })
2604
+ entry.peekCount = budget.targetedCount
2605
+ entry.peekRosterCount = budget.rosterCount
2606
+ if (!budget.ok) return { content: [{ type: 'text', text: budget.reason }] }
2596
2607
  const targetEntry = target?.kind === 'agent' ? sessions.get(target.id) : null
2597
2608
  const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
2598
2609
  + nativeClaudeFallbackHint({ parentRuntime: entry.runtime, parentModels: entry.models, targetRuntime: targetEntry?.runtime, targetLog: targetEntry?.log })
@@ -2705,6 +2716,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2705
2716
  // The injected turn starts fresh budgets, one hop deeper (loop breaker).
2706
2717
  te.hop = (entry.hop || 0) + 1
2707
2718
  te.peekCount = 0
2719
+ te.peekRosterCount = 0
2708
2720
  te.postCount = 0
2709
2721
  // Echo the injected prompt into the TARGET lane so both people see what
2710
2722
  // arrived (rides the existing code-event 'you' path — no new topic).
@@ -2762,7 +2774,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2762
2774
  if (args?.name) { delete termNames[newId]; saveNames(room, termNames) }
2763
2775
  return okText('Could not open the main conductor terminal — the machine terminal cap may have just been reached.')
2764
2776
  }
2765
- conductor.peekCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2777
+ conductor.peekCount = 0; conductor.peekRosterCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2766
2778
  announce()
2767
2779
  const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2768
2780
  const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
@@ -2837,7 +2849,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2837
2849
  resolvedModel: resolved.model,
2838
2850
  },
2839
2851
  })
2840
- } catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
2852
+ } catch (error) {
2853
+ process.stderr.write(`\n ${A.red}✗ dispatch preview rejected — ${error?.message || error}${A.rst}\n`)
2854
+ return okText('Dispatch preview could not be built safely. No lane was created.')
2855
+ }
2841
2856
  const currentNow = Date.now()
2842
2857
  const current = dispatchContext(currentNow)
2843
2858
  const authorization = authorizeDirectDispatch({
@@ -2880,7 +2895,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2880
2895
  openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: effectiveArgs.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2881
2896
  const ne = sessions.get(newId)
2882
2897
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
2883
- ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
2898
+ ne.peekCount = 0; ne.peekRosterCount = 0; ne.postCount = 0; ne.spawnTimes = []
2884
2899
  announce() // (defensive) ensure the web renders the new tab live
2885
2900
  if (args?.task) {
2886
2901
  // The user-visible relay reinforces (but never defines) the structural
@@ -4244,6 +4259,7 @@ channel
4244
4259
  // through here, so its deeper hop level + spent budget stick until a person
4245
4260
  // speaks again.
4246
4261
  s.peekCount = 0
4262
+ s.peekRosterCount = 0
4247
4263
  s.postCount = 0
4248
4264
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
4249
4265
  s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
@@ -4508,7 +4524,7 @@ channel
4508
4524
  const s = payload?.term && sessions.get(payload.term)
4509
4525
  const p = s && payload.id && s.pending.get(payload.id)
4510
4526
  if (!p) return
4511
- // `tpcode` is a public transport. Treat the frame only as a wake-up hint and
4527
+ // `tpcode` is membership-gated transport. Treat the frame only as a wake-up hint and
4512
4528
  // reproduce its resolved item + unique delivery attempt through owner-authenticated
4513
4529
  // RLS reads before touching the local pending resolver. A forged broadcast then
4514
4530
  // has no more authority than packet noise.
@@ -240,7 +240,6 @@ const TP_ROOM_REMINDER = [
240
240
  'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
241
241
  THINKPOOL_RUNTIME_TURN_REMINDER,
242
242
  'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
243
- 'PEER: before substantive work, check what the other lanes are doing (the ROOM NOW snapshot below, read_terminal for detail; list_sessions/read_session across rooms) — coordinate on shared files/branches instead of colliding.',
244
243
  'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
245
244
  'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
246
245
  ].join(' ')
@@ -748,14 +747,13 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
748
747
  THINKPOOL_CASCADE_RULE,
749
748
  'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',
750
749
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
751
- '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. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
750
+ 'CROSS-TERMINAL AWARENESS: ROOM NOW is the default roster and already satisfies the room check when it has enough detail. Do not repeat it with a no-argument read_terminal call unless it is missing or truncated. Use a targeted read_terminal call only when the current task depends on a specific lane’s detailed activity; never poll. Identify a terminal by its NAME or stable ref/id, never by an on-screen number like "Terminal 2" — positional labels renumber when a terminal is closed. The tool is read-only and its optional roster lookup is budgeted separately from bounded targeted transcript reads.',
752
751
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
753
- 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
752
+ 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
754
753
  'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
755
754
  'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
756
755
  'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
757
756
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
758
- 'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
759
757
  'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
760
758
  '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.',
761
759
  ].join(' '),
@@ -18,11 +18,38 @@ export const PEEK = {
18
18
  // explicitly when a diagnosis genuinely needs deeper sibling history.
19
19
  defaultLines: 20,
20
20
  maxLines: 200,
21
- perTurnCap: 10, // read_terminal calls allowed per user turn (bridge resets it)
21
+ perTurnCap: 10, // targeted transcript reads allowed per user turn (bridge resets it)
22
+ rosterPerTurnCap: 1, // no-argument roster reads use a separate allowance; ROOM NOW is default
22
23
  lineCap: 200, // per-line truncation
23
24
  rosterPreview: 100, // last-line preview length in the roster listing
24
25
  }
25
26
 
27
+ // ROOM NOW already gives every agent a compact live roster. Keep the one optional
28
+ // full-roster lookup separate from transcript reads so a redundant discovery call
29
+ // cannot consume the budget needed to collect workers and run the final review.
30
+ // Unknown terminal refs are rejected before this helper is called and spend neither
31
+ // allowance. The bridge stores the returned counters on the current lane.
32
+ export const readTerminalBudgetDecision = ({ targeted = false, targetedCount = 0, rosterCount = 0 } = {}, limits = PEEK) => {
33
+ const nextTargeted = Math.max(0, Number(targetedCount) || 0)
34
+ const nextRoster = Math.max(0, Number(rosterCount) || 0)
35
+ if (targeted) {
36
+ if (nextTargeted >= limits.perTurnCap) return {
37
+ ok: false,
38
+ targetedCount: nextTargeted,
39
+ rosterCount: nextRoster,
40
+ reason: `Cross-terminal transcript read limit reached for this turn (${limits.perTurnCap}). Stop polling and continue with the worker results already collected; a new person-authored turn resets the allowance.`,
41
+ }
42
+ return { ok: true, targetedCount: nextTargeted + 1, rosterCount: nextRoster }
43
+ }
44
+ if (nextRoster >= limits.rosterPerTurnCap) return {
45
+ ok: false,
46
+ targetedCount: nextTargeted,
47
+ rosterCount: nextRoster,
48
+ reason: `The full terminal roster was already read this turn (${limits.rosterPerTurnCap}/${limits.rosterPerTurnCap}). Use the current ROOM NOW snapshot or read one relevant terminal by ref or name.`,
49
+ }
50
+ return { ok: true, targetedCount: nextTargeted, rosterCount: nextRoster + 1 }
51
+ }
52
+
26
53
  // One shared lane-status classifier for every roster consumer: the bridge wire,
27
54
  // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
28
55
  // keep the displayed age current without a broadcast every second.
@@ -320,7 +347,7 @@ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees =
320
347
  return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
321
348
  })
322
349
  if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
323
- out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
350
+ out.push(`Other lanes in this room right now (this is the roster; use targeted read_terminal only for needed detail):\n${rows.join('\n')}`)
324
351
  }
325
352
  const wtAll = Array.isArray(worktrees) ? worktrees : []
326
353
  const wt = wtAll.slice(0, limits.wtCap)
@@ -447,7 +474,10 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
447
474
  // rechecks immediately before it spends a lane. No token, provider credential,
448
475
  // host path, or mutable process object is copied into the preview.
449
476
  const DISPATCH_ARG_KEYS = Object.freeze(['mode', 'model', 'name', 'provider', 'runtime', 'sliceType', 'task'])
450
- const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
477
+ // `review` is an internal Codex-only mode selected by structuredModeForSlice.
478
+ // It is not user-selectable in the spawn schema, but it must survive the immutable
479
+ // preview boundary or every Codex review lane fails before creation.
480
+ const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'review'])
451
481
  const DISPATCH_RUNTIMES = new Set(['claude', 'codex', 'hermes'])
452
482
  const DISPATCH_SLICES = new Set(['scaffold', 'feature', 'fix', 'review'])
453
483
  const secretValue = /((?<![a-z0-9])sk-[a-z0-9_-]{8,}|(?<![a-z0-9])gsk_[a-z0-9_-]{8,}|(?<![a-z0-9])AIza[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/ig
@@ -524,7 +554,8 @@ export function buildDispatchPreview ({ args, initiatorTerminalId, roomCode, bri
524
554
  }),
525
555
  permissions: Object.freeze({ mode: exactArgs.mode, consequence: exactArgs.mode === 'bypassPermissions'
526
556
  ? 'Unattended local access within this worker’s sandbox and tool policy.'
527
- : exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
557
+ : exactArgs.mode === 'review' ? 'Read-only inspection of the pinned parent snapshot; writes and approval escapes are unavailable.'
558
+ : exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
528
559
  worktree: 'Automatically creates one visible lateral worker lane and an isolated git worktree on this machine.',
529
560
  caps,
530
561
  })
@@ -1,7 +1,12 @@
1
1
  // Cumulative provider snapshots are replace-in-place UI state, not transcript
2
2
  // facts. Deliver the first immediately and coalesce a burst to its latest frame;
3
3
  // any durable event flushes the pending snapshot first to preserve wire order.
4
- export function createCumulativeEventRelay(emit, waitMs = 150) {
4
+ //
5
+ // 75ms is a readable 13.3fps ceiling: quick enough for an active reply without
6
+ // turning every provider delta into a realtime broadcast or a full UI repaint.
7
+ export const CUMULATIVE_EVENT_RELAY_WAIT_MS = 75
8
+
9
+ export function createCumulativeEventRelay(emit, waitMs = CUMULATIVE_EVENT_RELAY_WAIT_MS) {
5
10
  let lastSentAt = 0
6
11
  let pending = null
7
12
  let timer = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.327",
3
+ "version": "0.7.330",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "bridge-service.sh",
11
12
  "command-guidance.mjs",
12
13
  "abort-turn-barrier.mjs",
13
14
  "host-memory.mjs",
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) {
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 14,
3
+ "bundleVersion": 15,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
7
- "version": 2,
7
+ "version": 3,
8
8
  "routes": [
9
9
  {
10
10
  "id": "room-awareness",
11
11
  "tools": ["read_terminal"],
12
12
  "trigger": "\\b(other|another|sibling|peer)\\s+(lane|terminal)|\\bread_terminal\\b",
13
- "prompt": "When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, inspect it with read_terminal before acting."
13
+ "prompt": "When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, use the current ROOM NOW snapshot as the default roster. Do not repeat it with a no-argument read_terminal call unless the snapshot is missing or truncated; that roster lookup has its own one-call allowance. Read only the relevant terminal by ref or name when its detailed transcript is actually needed. Never poll with read_terminal: wait for the room snapshot or completion signal to show a state change, collect each finished owned worker once, then close it immediately. Preserve the bounded targeted-read allowance for worker collection and final review."
14
14
  },
15
15
  {
16
16
  "id": "cross-room-awareness",
@@ -22,7 +22,7 @@
22
22
  "id": "visible-handoff",
23
23
  "tools": ["post_to_terminal", "post_to_session"],
24
24
  "trigger": "\\b(hand[ -]?off|tell|send|post)\\b.{0,40}\\b(lane|terminal|room|session|agent)\\b|\\b(post_to_terminal|post_to_session)\\b",
25
- "prompt": "When the people want a handoff, read the target first, then use post_to_terminal or post_to_session; let the room approval contract handle consent."
25
+ "prompt": "When the people want a handoff, read the target once if its current details are not already available, then use post_to_terminal or post_to_session; let the room approval contract handle consent."
26
26
  }
27
27
  ],
28
28
  "impact": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  "id": "work-routing",
44
- "version": 5,
44
+ "version": 6,
45
45
  "providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
46
46
  "openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
47
47
  "routes": [
@@ -49,10 +49,10 @@
49
49
  "id": "work-routing",
50
50
  "tools": ["spawn_terminal", "open_main_terminal", "close_terminal"],
51
51
  "trigger": "\\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
52
- "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
52
+ "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify each result with one targeted read_terminal call after completion, then close_terminal immediately. Do not poll workers or repeat the ROOM NOW roster. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
53
53
  }
54
54
  ],
55
- "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
55
+ "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. ROOM NOW is the default roster and completion signals wake the conductor; do not spend the bounded read allowance polling or repeating a no-argument roster. After dispatch, remain responsible: once an owned worker is finished, make one targeted read_terminal call to collect its result, verify its claim, close_terminal immediately, and dispatch newly unblocked work. Preserve enough targeted reads for every open worker and the final adversarial review; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
56
56
  "impact": [
57
57
  {"path": "bridge/bridge.mjs", "diffPattern": "spawn_terminal|open_main_terminal|close_terminal|cascadeRole|spawnDepth"},
58
58
  {"path": "bridge/lane-lifecycle.mjs"},