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 +236 -199
- package/account.mjs +30 -34
- package/bridge.mjs +87 -178
- package/package.json +1 -1
- package/terminal-name.mjs +15 -4
package/README.md
CHANGED
|
@@ -1,235 +1,272 @@
|
|
|
1
|
-
# thinkpool-pair
|
|
1
|
+
# `thinkpool-pair`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
node bridge.mjs <ROOM> # picks an installed agent, shared into room <ROOM>
|
|
25
|
+
npx thinkpool-pair@latest
|
|
20
26
|
```
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
The
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
79
|
+
npx thinkpool-pair@latest set-default-dir /path/to/project
|
|
190
80
|
```
|
|
191
81
|
|
|
192
|
-
|
|
82
|
+
Bind one room explicitly when needed:
|
|
193
83
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
112
|
+
Set up an isolated Hermes profile with one of:
|
|
218
113
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
npx thinkpool-pair@latest install-service <ROOM> -- claude
|
|
138
|
+
npx thinkpool-pair@latest uninstall-service <ROOM>
|
|
229
139
|
```
|
|
230
140
|
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
+
acctControl.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
|
|
569
574
|
const nonce = payload?.nonce
|
|
570
575
|
const reply = async (ok, error) => {
|
|
571
|
-
try { await
|
|
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
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
675
|
-
|
|
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
|
|
683
|
-
|
|
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,
|
|
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
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', '
|
|
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) {
|