@kdonev/termscape 0.1.0 → 0.1.2

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.
Files changed (89) hide show
  1. package/README.md +211 -79
  2. package/dist/agents/detect.d.ts +27 -0
  3. package/dist/agents/detect.d.ts.map +1 -0
  4. package/dist/agents/detect.js +210 -0
  5. package/dist/agents/detect.js.map +1 -0
  6. package/dist/agents/profiles.d.ts +119 -2
  7. package/dist/agents/profiles.d.ts.map +1 -1
  8. package/dist/agents/profiles.js +224 -4
  9. package/dist/agents/profiles.js.map +1 -1
  10. package/dist/agents/templates.d.ts +100 -0
  11. package/dist/agents/templates.d.ts.map +1 -0
  12. package/dist/agents/templates.js +165 -0
  13. package/dist/agents/templates.js.map +1 -0
  14. package/dist/agents/wiring.d.ts +30 -1
  15. package/dist/agents/wiring.d.ts.map +1 -1
  16. package/dist/agents/wiring.js +182 -10
  17. package/dist/agents/wiring.js.map +1 -1
  18. package/dist/cli.js +51 -14
  19. package/dist/cli.js.map +1 -1
  20. package/dist/db/migrations.d.ts.map +1 -1
  21. package/dist/db/migrations.js +73 -0
  22. package/dist/db/migrations.js.map +1 -1
  23. package/dist/db/store.d.ts +53 -1
  24. package/dist/db/store.d.ts.map +1 -1
  25. package/dist/db/store.js +118 -5
  26. package/dist/db/store.js.map +1 -1
  27. package/dist/hub.d.ts +264 -6
  28. package/dist/hub.d.ts.map +1 -1
  29. package/dist/hub.js +549 -33
  30. package/dist/hub.js.map +1 -1
  31. package/dist/hub.tgz +0 -0
  32. package/dist/mcp/server.d.ts +3 -0
  33. package/dist/mcp/server.d.ts.map +1 -1
  34. package/dist/mcp/server.js +27 -2
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/protocol/domain.d.ts +88 -0
  37. package/dist/protocol/domain.d.ts.map +1 -1
  38. package/dist/protocol/domain.js +114 -0
  39. package/dist/protocol/domain.js.map +1 -1
  40. package/dist/protocol/mcp-tools.d.ts +43 -0
  41. package/dist/protocol/mcp-tools.d.ts.map +1 -1
  42. package/dist/protocol/mcp-tools.js +65 -0
  43. package/dist/protocol/mcp-tools.js.map +1 -1
  44. package/dist/protocol/peer.d.ts +38 -1
  45. package/dist/protocol/peer.d.ts.map +1 -1
  46. package/dist/protocol/peer.js +41 -2
  47. package/dist/protocol/peer.js.map +1 -1
  48. package/dist/protocol/ws.d.ts +259 -0
  49. package/dist/protocol/ws.d.ts.map +1 -1
  50. package/dist/protocol/ws.js +169 -5
  51. package/dist/protocol/ws.js.map +1 -1
  52. package/dist/remote/deployer.d.ts.map +1 -1
  53. package/dist/remote/deployer.js +15 -2
  54. package/dist/remote/deployer.js.map +1 -1
  55. package/dist/remote/join-script.d.ts.map +1 -1
  56. package/dist/remote/join-script.js +34 -2
  57. package/dist/remote/join-script.js.map +1 -1
  58. package/dist/remote/lan.d.ts +73 -5
  59. package/dist/remote/lan.d.ts.map +1 -1
  60. package/dist/remote/lan.js +119 -10
  61. package/dist/remote/lan.js.map +1 -1
  62. package/dist/remote/peer-serve.d.ts.map +1 -1
  63. package/dist/remote/peer-serve.js +32 -3
  64. package/dist/remote/peer-serve.js.map +1 -1
  65. package/dist/remote/peer.d.ts +7 -0
  66. package/dist/remote/peer.d.ts.map +1 -1
  67. package/dist/remote/peer.js +22 -0
  68. package/dist/remote/peer.js.map +1 -1
  69. package/dist/remote/registry.d.ts +70 -2
  70. package/dist/remote/registry.d.ts.map +1 -1
  71. package/dist/remote/registry.js +134 -10
  72. package/dist/remote/registry.js.map +1 -1
  73. package/dist/server.d.ts +22 -3
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +239 -117
  76. package/dist/server.js.map +1 -1
  77. package/dist/session/manager.d.ts +60 -4
  78. package/dist/session/manager.d.ts.map +1 -1
  79. package/dist/session/manager.js +198 -41
  80. package/dist/session/manager.js.map +1 -1
  81. package/dist/session/pty.d.ts +19 -0
  82. package/dist/session/pty.d.ts.map +1 -1
  83. package/dist/session/pty.js +84 -1
  84. package/dist/session/pty.js.map +1 -1
  85. package/package.json +1 -1
  86. package/web/assets/{index-BOqZOoep.js → index-9Z_8xZqk.js} +10 -6
  87. package/web/assets/index-DheUm5dl.css +1 -0
  88. package/web/index.html +2 -2
  89. package/web/assets/index-B2JtCtHa.css +0 -1
package/README.md CHANGED
@@ -8,9 +8,11 @@ hub exposes. Agents can look each other up, send each other messages, spawn
8
8
  helpers into their workspace, and check on each other's terminals. A message
9
9
  from one agent is delivered by typing it into the other's terminal, immediately.
10
10
 
11
- Local-first: the hub runs on your machine, binds loopback only, and the UI is a
12
- browser tab. Other machines run the same hub as a daemon and appear on the same
13
- canvas.
11
+ Local-first: the hub runs on your machine and the UI is a browser tab. It binds
12
+ every interface, so the canvas opens on your phone or a second screen as well
13
+ with its token — and another machine can attach itself from the join page.
14
+ `--listen loopback` keeps the whole thing to this machine. Other machines run
15
+ the same hub as a daemon and appear on the same canvas.
14
16
 
15
17
  ## Getting started
16
18
 
@@ -23,9 +25,22 @@ if you would rather it did not; the URL is printed either way. Nothing is
23
25
  installed system-wide: state lives in `~/.termscape`, and deleting that
24
26
  directory is the uninstall.
25
27
 
28
+ The hub is reachable from your network, so the same URL — token and all — opens
29
+ the canvas on a phone or a second screen, and the **+ machine** dialog already
30
+ has a join link in it for the second machine; see
31
+ [Adding another machine](#adding-another-machine). To keep the hub to this
32
+ machine entirely — no join page, nothing off 127.0.0.1:
33
+
34
+ ```bash
35
+ npx @kdonev/termscape --listen loopback
36
+ ```
37
+
26
38
  Slide out the **machines** panel: every machine, the workspaces on it, and
27
39
  the agents in each. Point a workspace at a folder there, and start an agent
28
- in it.
40
+ in it. Adding, editing and removing all open a dialog over the canvas, so the
41
+ node you acted on stays where it was and a refusal — a folder that is not
42
+ there, a rename the addresses will not allow — arrives in the dialog next to
43
+ the field, with what you typed still in it.
29
44
 
30
45
  - **Scroll** to pan, **Ctrl/⌘ + scroll** to zoom, **Ctrl/⌘ + 1** to fit,
31
46
  **Ctrl/⌘ + 2** to zoom to one terminal
@@ -35,6 +50,8 @@ in it.
35
50
  ordinary pace is left alone
36
51
  - Below 60% zoom terminals become preview cards — zoom in to interact
37
52
  - Clicking an agent in the panel brings the canvas to it
53
+ - Clicking the canvas closes the panel, as does **Escape** — which closes the
54
+ panel first and clears the selection only once it is shut
38
55
 
39
56
  ## Requirements
40
57
 
@@ -66,20 +83,18 @@ next window.
66
83
 
67
84
  ## Adding another machine
68
85
 
69
- Start the hub so the other machine can see it, then let that machine come to
70
- you:
71
-
72
- ```bash
73
- npx @kdonev/termscape --listen lan
74
- ```
86
+ Nothing to turn on: a hub started with no flags at all hands out join links.
87
+ The startup banner prints an `enroll:` URL alongside the usual one, and the
88
+ **+ machine** dialog shows the same URL with a copy button.
75
89
 
76
- (From a clone that is `npm run dev -- --listen lan`, or
77
- `npm run dev -w @termscape/hub -- --listen lan` if you are calling the
78
- workspace directly npm needs the `--` to hand flags to the hub rather than
79
- reading them itself.)
90
+ That is a deliberate trade and worth knowing about, because `/join` is the one
91
+ page served without your token it has to be typed by hand on a machine that
92
+ has nothing yet. So on a network you do not trust, anyone who can reach this
93
+ hub can pull the installer and put a machine on your canvas. `--listen
94
+ loopback` is how you say no, and it turns off the network entirely.
80
95
 
81
- The hub prints an `enroll:` URL alongside the usual one. Open it **on the
82
- machine you want to add** and run the command it shows:
96
+ Open the join URL **on the machine you want to add** and run the command it
97
+ shows:
83
98
 
84
99
  ```bash
85
100
  curl -fsSL http://studio:7777/join.sh | sh # macOS, Linux
@@ -134,9 +149,12 @@ have actually changed, and checks that tree really loads before trusting it.
134
149
 
135
150
  Two things worth knowing:
136
151
 
137
- - `--listen` is opt-in and off by default. With it, your hub is reachable on
138
- that network, and anyone who can load the join page can attach a machine to
139
- your canvas. Every other route still requires the token.
152
+ - The **join page** answers by default, on the same interfaces the canvas does.
153
+ Anyone who can reach your hub can load that page and attach a machine to your
154
+ canvas; every *other* route still requires the token. It was opt-in once, and
155
+ the reason it is not any more is that the add-machine dialog had nothing to
156
+ show but an instruction to restart the hub with a flag — a feature reachable
157
+ only that way is a feature nobody uses. `--listen loopback` is the way back.
140
158
  - Each download carries a single-use key that expires in 15 minutes. Once a
141
159
  machine has joined it keeps a durable token in `~/.termscape/host-token` and
142
160
  rejoins by itself after a reboot or a dropped link — its agents keep running
@@ -153,20 +171,72 @@ tunnel. Same protocol, opposite direction.
153
171
  |---|---|
154
172
  | `whoami` | your address, workspace and working directory |
155
173
  | `list_agents` | everyone on the canvas, across every host, and whether they are busy |
174
+ | `list_templates` | the saved ways of starting an agent, and what each one runs |
156
175
  | `send_message` | type a message into another agent's terminal, right now |
157
176
  | `spawn_agent` | start a helper in your workspace, with an optional first task |
158
177
  | `read_screen` | look at another agent's terminal without interrupting it |
159
178
  | `set_status` | label your own window so the human can see what you are doing |
160
179
  | `stop_agent` | stop an agent you spawned |
180
+ | `propose_template` | ask the human to save a way of starting an agent, under a name |
181
+
182
+ `list_templates` is what makes `spawn_agent` usable for anything but a bare
183
+ CLI: its `profile` takes a template id, and this is how an agent finds out
184
+ which ids exist. It reports the *names* of the environment variables a template
185
+ sets and never their values, which are credentials more often than not.
186
+
187
+ `propose_template` is the only one that asks rather than does. A template
188
+ changes how *future* agents are launched, on every machine, with nobody
189
+ necessarily watching — so the canvas shows it to a human, who can edit it
190
+ before accepting or decline it outright. The tool returns as soon as they have
191
+ been shown it rather than blocking until they answer, and the decision is typed
192
+ back into the agent's terminal. Nothing is stored unless someone says yes.
161
193
 
162
194
  Every agent is also given a brief explaining its address, its peers, and that
163
195
  text arriving as `[from <address>] ...` is a colleague rather than the human.
164
196
 
165
197
  ## Agent profiles
166
198
 
167
- An agent CLI is configuration, not code. Built-ins are `claude` and `shell`
168
- (a plain terminal, no agent wiring). Override or add profiles in
169
- `~/.termscape/agents.toml`:
199
+ An agent CLI is configuration, not code. Built-ins are `claude`, `codex`,
200
+ `gemini`, `opencode` and `shell`.
201
+
202
+ Every one of them except `shell` is wired to the hub's MCP endpoint: each gets an
203
+ address, a brief and the `send_message` tool set. They arrive there by four
204
+ different routes, because no two of these CLIs configure an MCP server the same
205
+ way — and **none of them writes to a file you own**, so there is nothing left
206
+ behind when a session ends or when the hub is killed rather than stopped.
207
+
208
+ | agent | how it reaches the hub | brief | resume |
209
+ | --- | --- | --- | --- |
210
+ | `claude` | `--mcp-config` on a generated file | `--append-system-prompt-file` | `--resume <uuid>` |
211
+ | `codex` | `-c mcp_servers.…` overrides, one run only | typed in at startup | restarts clean |
212
+ | `gemini` | `GEMINI_CLI_SYSTEM_SETTINGS_PATH` at a generated file | typed in at startup | restarts clean |
213
+ | `opencode` | `OPENCODE_CONFIG_CONTENT`, no file anywhere | typed in at startup | restarts clean |
214
+
215
+ The last three are typed at rather than handed a brief because none of them can
216
+ *append* to its system prompt — Codex's `base_instructions` and Gemini's
217
+ `GEMINI_SYSTEM_MD` each replace the whole thing, which would cost the agent its
218
+ own tool instructions. None of the three is resumable either: Codex mints a
219
+ session id it will not accept from us, and Gemini accepts one but resumes by
220
+ list index instead. They restart clean, and are briefed again when they do.
221
+
222
+ Two details worth knowing, because both are easy to get wrong:
223
+
224
+ - **Codex's bearer token goes in the environment, never `-c`.** Config
225
+ overrides land in the command line, where any other user on the machine can
226
+ read them. `bearer_token_env_var` exists precisely for this.
227
+ - **opencode is configured entirely from the environment.** Its config is
228
+ handed over as a string, merged with your own rather than replacing it, so
229
+ your models, themes and your own MCP servers survive the session. Note that
230
+ `opencode mcp add` is *not* how this is done: that command writes to
231
+ `~/.config/opencode/opencode.json` and ignores `OPENCODE_CONFIG` while doing
232
+ it. Setting the variable also stops opencode writing its default config file
233
+ on start, so a session leaves nothing behind at all.
234
+
235
+ `shell` is the one profile that is not an agent, and it is not one in a way no
236
+ flag can fix: it runs a shell, so text typed at it is executed rather than read.
237
+ It gets no brief, and its profile says so rather than relying on a default.
238
+
239
+ Override or add profiles in `~/.termscape/agents.toml`:
170
240
 
171
241
  ```toml
172
242
  [my-agent]
@@ -175,8 +245,116 @@ args = ["--mcp-config", "{{mcp_config_path}}"]
175
245
  status = "heuristic" # or "hooks", for exact turn boundaries
176
246
  ready_hint = "[$#>%] ?$" # prompt regex, for the idle indicator
177
247
  inject = "bracketed" # bracketed paste, or "raw"
248
+ brief = "typed" # "flag" if it can append a system prompt,
249
+ # "none" for a terminal that would execute one
250
+ version_args = ["--version"] # how to ask its version, for the panel
251
+ models_args = ["models"] # optional: one model per line on stdout
252
+ models = ["opus", "sonnet"] # the answer when it has no listing command
253
+ model_args = ["--model", "{{model}}"] # how it spells a model, if it takes one
254
+ effort_args = ["--effort", "{{effort}}"] # and an effort
255
+ efforts = ["low", "high"] # the levels it documents
178
256
  ```
179
257
 
258
+ ### Templates
259
+
260
+ The picker offers **templates**, not CLIs. A template is an agent plus a model,
261
+ an effort and an opening instruction — a saved answer to all four, picked once
262
+ instead of typed every time. Every agent gets a bare template under its own
263
+ name, so `claude` and `shell` are still there and nothing that worked stops
264
+ working.
265
+
266
+ Templates are a root of their own in the panel, next to the machines — they are
267
+ config rather than a place, and one template is used on every machine, so it
268
+ does not live under one. **+ template** makes one, `edit` changes it, `×`
269
+ removes it. The list sits below the machines and starts collapsed: machines are
270
+ what you work in every day, templates are what you set up once and then forget.
271
+
272
+ They can also be written by hand, and a hand-written one wins:
273
+
274
+ ```toml
275
+ [template.reviewer]
276
+ agent = "claude"
277
+ model = "opus"
278
+ effort = "high"
279
+ prompt = "Review the diff on this branch for correctness bugs. Report, do not fix."
280
+
281
+ # Set for the agents this template starts, on top of what the CLI already gets.
282
+ [template.reviewer.env]
283
+ ANTHROPIC_BASE_URL = "https://proxy.internal"
284
+ ```
285
+
286
+ - Templates made in the panel are stored in `state.db`, the same place
287
+ workspaces and hosts already live. `agents.toml` is a second, read-only
288
+ source: the hub never writes it, so a formatter cannot eat the comments and
289
+ ordering of a file you edit by hand.
290
+ - When both declare the same name **the file wins**, and the dialog refuses the
291
+ name rather than storing a row that would never appear. Someone who wrote a
292
+ template by hand meant it.
293
+ - An agent's own bare template is derived, not stored. Editing one makes a
294
+ stored template that shadows it; removing that reveals the bare one again
295
+ rather than leaving a gap.
296
+ - Removing a template takes nothing from the agents it already started. They
297
+ keep their model, their effort and their ability to resume, because a session
298
+ records what its template resolved to rather than looking it up again.
299
+
300
+ Three words, kept apart deliberately: an **agent** is the CLI program, a
301
+ **template** is what you pick from the list, and a **session** is one running
302
+ instance with an address and a window.
303
+
304
+ - **A template holds values, not arguments.** Claude Code takes `--model` and
305
+ `--effort`; opencode takes `-m provider/model` and has no effort setting on
306
+ its TUI at all; Codex takes `-m` but spells effort as a config override,
307
+ `-c model_reasoning_effort=…`, because it has no `--effort` flag. So the
308
+ template says *which* model, and the agent declares how to spell it. An agent that declares nothing takes nothing, and a template
309
+ asking for a model or an effort it cannot spell is a configuration error
310
+ reported when the file loads — visible in the dialog, not a flag silently
311
+ dropped at launch.
312
+ - **The first instruction is typed in once the CLI is up**, not passed as an
313
+ argument, and it does not repeat when a session is resumed. It is how the
314
+ session started, not what it is.
315
+ - **A resumed session comes back on the model it left with.** What the template
316
+ resolved to is recorded on the session, because resume rebuilds the command
317
+ line rather than replaying it — and because a template can be edited
318
+ afterwards.
319
+ - **Starting an agent on another machine sends values, not a template name.**
320
+ The two machines do not share config, so the name is resolved here first.
321
+ - **A template can set environment variables**, in the panel as `NAME=value`
322
+ lines or as a `[template.<name>.env]` table in the file. They are set for the
323
+ agents that template starts, on top of whatever the CLI would inherit
324
+ anyway, and they win over what the agent profile sets on the rare name both
325
+ name. This is where an API key, a base URL or a feature flag that
326
+ distinguishes two otherwise identical templates belongs — before it, the
327
+ difference could only live in whichever shell the hub happened to be started
328
+ from, which is not a per-template answer at all. Like the model, they are
329
+ recorded on the session, so a resumed agent comes back in the environment it
330
+ was launched in. An agent proposing a template cannot ask for any: choosing
331
+ what the next agent's credentials are is not something to review one dialog
332
+ at a time.
333
+
334
+ ### What is actually installed
335
+
336
+ Each machine probes its own `PATH` and reports back, so the panel shows, per
337
+ machine, which agents are there, what version each is, and the models it
338
+ offers. That is per machine on purpose: a host has its own `PATH`, and starting
339
+ an agent it does not have used to fail at launch inside a terminal window,
340
+ where the error reads like the hub is broken.
341
+
342
+ - A declared agent that is **not** installed stays in the list, greyed out,
343
+ naming the command that was not found — rather than vanishing, which looks
344
+ like the config was ignored.
345
+ - Models are enumerated where the CLI can be asked (`opencode models` returns
346
+ a few hundred) and declared in the profile where it cannot. Claude Code has
347
+ no listing command; its `--help` documents the aliases instead, and it takes
348
+ a full model name as readily as an alias. Codex is declared too, for a
349
+ different reason: `codex debug models` does render the real catalog, but it
350
+ is a debug command answering with half a megabyte of JSON rather than the
351
+ one-per-line stdout the profile reads, so the profile carries the slugs that
352
+ catalog marks visible. In every case a full model name outside the list is
353
+ still accepted — the list is what the dropdown suggests, never a limit.
354
+ - Probing runs after the hub is already serving and never blocks it. The first
355
+ page load usually shows *checking…*, and fills in a moment later. **check
356
+ again** in the start-an-agent dialog re-probes every machine.
357
+
180
358
  ## State and restart
181
359
 
182
360
  SQLite at `~/.termscape/state.db` holds everything needed to redraw the canvas
@@ -213,7 +391,14 @@ Agents can type into each other's terminals and spawn more agents. That is the
213
391
  feature, and it is the risk surface: an agent that reads a hostile repository
214
392
  could be talked into sending an attacker's text to a peer.
215
393
 
216
- - The hub binds `127.0.0.1` only, never a network interface. Remote hubs too.
394
+ - The hub binds every interface, so the canvas is reachable from your network —
395
+ but only with its token, which is minted per run and never printed anywhere
396
+ the network can read. `--listen loopback` narrows it to this machine.
397
+ - A hub running `--headless` — one that joined a canvas, or was deployed over
398
+ ssh and is reached through its tunnel — stays on `127.0.0.1` regardless.
399
+ - `/join` is the only route served without the token, and it answers by
400
+ default on every interface the hub bound. `--listen loopback` turns it off,
401
+ along with the rest of the network.
217
402
  - Every agent gets its own bearer token. The sender of a message is taken from
218
403
  that token, never from the arguments, so attribution cannot be forged.
219
404
  - Messages are length-capped, rate-limited per sender, and always arrive with a
@@ -225,63 +410,10 @@ could be talked into sending an attacker's text to a peer.
225
410
  - An agent may only stop agents it spawned.
226
411
  - `--dangerously-skip-permissions` is never a default.
227
412
 
228
- ## Development
229
-
230
- From a clone, rather than the published package:
231
-
232
- ```bash
233
- git clone https://github.com/kdonev/termscape.git
234
- cd termscape
235
- npm install
236
- npm run build
237
- npm run dev # hub with the built UI
238
- ```
239
-
240
- If `npm install` or `npm ci` tries to compile `better-sqlite3` and fails for
241
- want of a C++ toolchain, install this way instead:
242
-
243
- ```bash
244
- npm ci --ignore-scripts && npm rebuild node-pty
245
- ```
246
-
247
- Any install that reads `package-lock.json` misses better-sqlite3's
248
- `gypfile: false` and runs `node-gyp` on a module that already ships working
249
- prebuilds. Skipping install scripts avoids that; the rebuild puts back the one
250
- native build that is real, which is a no-op except on Linux. This is what CI
251
- does. It does not affect anyone installing the published package.
252
-
253
- ```bash
254
- npm test # unit + integration, no LLM required
255
- npm run typecheck
256
- npm run dev:web # vite dev server, expects a hub on :7777
257
- ```
258
-
259
- The integration tests spawn real PTYs and drive the real MCP endpoint, so
260
- `npm test` genuinely exercises message delivery and cross-host routing.
261
-
262
- ### Known quirks
263
-
264
- - On Windows, node-pty prints `AttachConsole failed` to stderr when killing a
265
- PTY from a process with no console attached (notably under the test runner).
266
- It is noise from a helper process and does not affect behaviour.
267
-
268
- ### Releasing
269
-
270
- The repo is a workspace of three private packages; what gets published is a
271
- single package assembled by `packages/hub/scripts/pack-npm.mjs` — the hub,
272
- the built UI it serves, and `@termscape/protocol` bundled inside it.
273
-
274
- ```bash
275
- npm run pack:npm # build everything, then stage and pack the tarball
276
- ```
413
+ ## Contributing
277
414
 
278
- Tagging `v<version>` runs `.github/workflows/release.yml`, which refuses a
279
- tag that disagrees with `packages/hub/package.json`, installs the packed
280
- tarball on macOS, Windows and Linux and checks each one starts and serves the
281
- canvas, and only then publishes to npm with provenance. Bump the version in
282
- `packages/hub/package.json` **and** `HUB_VERSION` in `packages/hub/src/hub.ts`
283
- together — peer compatibility is gated on the constant, and a test fails if
284
- the two drift.
415
+ Building from a clone, running the tests and cutting a release:
416
+ see [CONTRIBUTING.md](CONTRIBUTING.md).
285
417
 
286
418
  ## License
287
419
 
@@ -0,0 +1,27 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { AgentProfileInfo } from '../protocol/index.js';
3
+ import type { ProfileRegistry } from './profiles.js';
4
+ export declare class AgentDetector extends EventEmitter {
5
+ private readonly profiles;
6
+ private known;
7
+ private readonly cache;
8
+ private running;
9
+ constructor(profiles: ProfileRegistry);
10
+ /**
11
+ * What is known right now, without probing anything.
12
+ *
13
+ * Never async, and never empty: a profile that has not been probed yet is
14
+ * reported with `available: null`, which the panel draws as "checking".
15
+ * Detection must not be able to hold up a hub starting or a page loading.
16
+ */
17
+ snapshot(): AgentProfileInfo[];
18
+ /**
19
+ * Probe every profile. Safe to call from anywhere, including on a timer or
20
+ * from the browser; concurrent calls share the one run in flight rather
21
+ * than spawning every CLI twice.
22
+ */
23
+ refresh(): Promise<AgentProfileInfo[]>;
24
+ private run;
25
+ private probe;
26
+ }
27
+ //# sourceMappingURL=detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../../src/agents/detect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAI3C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,OAAO,KAAK,EAAgB,eAAe,EAAE,MAAM,eAAe,CAAC;AAkDnE,qBAAa,aAAc,SAAQ,YAAY;IAKjC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAJrC,OAAO,CAAC,KAAK,CAAuC;IACpD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IACvD,OAAO,CAAC,OAAO,CAA4C;gBAE9B,QAAQ,EAAE,eAAe;IAItD;;;;;;OAMG;IACH,QAAQ,IAAI,gBAAgB,EAAE;IAI9B;;;;OAIG;IACH,OAAO,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAQxB,GAAG;YAWH,KAAK;CA8DpB"}
@@ -0,0 +1,210 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { execFile } from 'node:child_process';
3
+ import { extname } from 'node:path';
4
+ import { platform } from 'node:process';
5
+ import { which } from './resolve.js';
6
+ /**
7
+ * What is actually installed on this machine, and what each one can be
8
+ * pointed at.
9
+ *
10
+ * The picker used to offer whatever agents.toml declared, whether or not the
11
+ * command existed, and said nothing about models. Two things make that worse
12
+ * than it sounds. A declared agent that is not installed fails at launch,
13
+ * inside a terminal window on the canvas, where the error reads like the hub
14
+ * is broken. And a model has to be typed from memory, which is how you find
15
+ * out that the CLI spells it differently than you thought.
16
+ *
17
+ * This answers both, per machine - and per machine is the point, because a
18
+ * host has its own PATH and this hub's answer says nothing about it.
19
+ */
20
+ /**
21
+ * How long any one probe gets before it is killed.
22
+ *
23
+ * Generous on purpose, and measured rather than guessed. `opencode models` was
24
+ * about 16 seconds on Windows through the .cmd shim npm installs when this was
25
+ * written; on opencode 1.18.29 it is nearer 38, which is how the old 30-second
26
+ * bound came to report a CLI with no models at all rather than its 367.
27
+ *
28
+ * That is the argument for erring long, not for chasing the number: a tighter
29
+ * bound does not make anything faster, it just turns a working CLI into one
30
+ * that reports no version and no models. Nothing waits on detection - it
31
+ * starts after the hub is already serving and the browser is told when each
32
+ * answer lands - so the only thing a long timeout costs is a late answer from
33
+ * a CLI that was never going to reply.
34
+ */
35
+ const PROBE_TIMEOUT_MS = 90_000;
36
+ /** Nobody scrolls a dropdown past this, and 395 lines is a real answer. */
37
+ const MAX_MODELS = 500;
38
+ export class AgentDetector extends EventEmitter {
39
+ profiles;
40
+ known = new Map();
41
+ cache = new Map();
42
+ running = null;
43
+ constructor(profiles) {
44
+ super();
45
+ this.profiles = profiles;
46
+ }
47
+ /**
48
+ * What is known right now, without probing anything.
49
+ *
50
+ * Never async, and never empty: a profile that has not been probed yet is
51
+ * reported with `available: null`, which the panel draws as "checking".
52
+ * Detection must not be able to hold up a hub starting or a page loading.
53
+ */
54
+ snapshot() {
55
+ return this.profiles.list().map((p) => this.known.get(p.id) ?? unprobed(p));
56
+ }
57
+ /**
58
+ * Probe every profile. Safe to call from anywhere, including on a timer or
59
+ * from the browser; concurrent calls share the one run in flight rather
60
+ * than spawning every CLI twice.
61
+ */
62
+ refresh() {
63
+ if (this.running)
64
+ return this.running;
65
+ this.running = this.run().finally(() => {
66
+ this.running = null;
67
+ });
68
+ return this.running;
69
+ }
70
+ async run() {
71
+ // In parallel: each of these is a process spawn that mostly sits waiting,
72
+ // and doing them in series makes the first load as slow as their sum.
73
+ const found = await Promise.all(this.profiles.list().map((p) => this.probe(p)));
74
+ const next = new Map(found.map((info) => [info.id, info]));
75
+ const changed = !same(this.known, next);
76
+ this.known = next;
77
+ if (changed)
78
+ this.emit('changed', found);
79
+ return found;
80
+ }
81
+ async probe(profile) {
82
+ const base = unprobed(profile);
83
+ const path = which(profile.command);
84
+ if (!path) {
85
+ // Stays in the list rather than vanishing: a declared agent that
86
+ // silently disappeared looks like the config was ignored, and the
87
+ // command that was not found is the thing worth showing.
88
+ return {
89
+ ...base,
90
+ available: false,
91
+ detail: `not found on PATH: ${profile.command}`,
92
+ };
93
+ }
94
+ const version = profile.versionArgs
95
+ ? await run(path, profile.versionArgs, profile.probeEnv).catch(() => null)
96
+ : null;
97
+ const key = `${path}|${version ?? ''}`;
98
+ const cached = this.cache.get(profile.id);
99
+ if (cached?.key === key) {
100
+ return {
101
+ ...base,
102
+ available: true,
103
+ commandPath: path,
104
+ version,
105
+ models: cached.models,
106
+ modelSource: cached.source,
107
+ };
108
+ }
109
+ const listed = profile.modelsArgs
110
+ ? await run(path, profile.modelsArgs, profile.probeEnv).catch(() => null)
111
+ : null;
112
+ let models = [];
113
+ let source = 'none';
114
+ if (listed !== null) {
115
+ models = listed
116
+ .split(/\r?\n/)
117
+ .map((l) => l.trim())
118
+ .filter((l) => l.length > 0)
119
+ .slice(0, MAX_MODELS);
120
+ source = 'listed';
121
+ }
122
+ if (models.length === 0 && profile.models?.length) {
123
+ // Either the CLI has no listing command, or its command failed. The
124
+ // declared list is the answer in both cases.
125
+ models = profile.models;
126
+ source = 'declared';
127
+ }
128
+ this.cache.set(profile.id, { key, models, source });
129
+ return {
130
+ ...base,
131
+ available: true,
132
+ commandPath: path,
133
+ version,
134
+ models,
135
+ modelSource: source,
136
+ };
137
+ }
138
+ }
139
+ /**
140
+ * A profile before anything has been asked of it.
141
+ *
142
+ * `shell` is the exception that is born answered: it is whatever COMSPEC or
143
+ * SHELL points at, it is always there, and there is nothing to probe.
144
+ */
145
+ function unprobed(p) {
146
+ const detectable = p.versionArgs !== undefined || p.modelsArgs !== undefined;
147
+ return {
148
+ id: p.id,
149
+ description: p.description,
150
+ mcp: p.mcp,
151
+ resumable: !!p.resumeArgs,
152
+ command: p.command,
153
+ commandPath: null,
154
+ available: detectable ? null : true,
155
+ version: null,
156
+ detail: null,
157
+ models: p.models ?? [],
158
+ modelSource: p.models?.length ? 'declared' : 'none',
159
+ takesModel: p.modelArgs !== undefined,
160
+ takesEffort: p.effortArgs !== undefined,
161
+ efforts: p.efforts ?? [],
162
+ };
163
+ }
164
+ /**
165
+ * Run one probe and return its stdout.
166
+ *
167
+ * Timed and killed rather than awaited indefinitely: the whole promise of
168
+ * this file is that a CLI which hangs on `--version` costs a slow dropdown
169
+ * and not a hub that will not boot. Windows shims (.cmd/.bat) are not
170
+ * executable images, so they go through cmd.exe exactly as spawning an agent
171
+ * does - `shell: true` would be the short version and would also hand the
172
+ * command line to a shell that reinterprets it.
173
+ */
174
+ function run(path, args, env) {
175
+ const ext = extname(path).toLowerCase();
176
+ const viaCmd = platform === 'win32' && (ext === '.cmd' || ext === '.bat');
177
+ const file = viaCmd ? (process.env.COMSPEC ?? 'cmd.exe') : path;
178
+ const argv = viaCmd ? ['/c', path, ...args] : args;
179
+ return new Promise((resolve, reject) => {
180
+ execFile(file, argv, {
181
+ timeout: PROBE_TIMEOUT_MS,
182
+ windowsHide: true,
183
+ maxBuffer: 4 * 1024 * 1024,
184
+ // Inherited and then overlaid: a probe still needs PATH and HOME to
185
+ // find and run anything at all.
186
+ env: env ? { ...process.env, ...env } : process.env,
187
+ }, (err, stdout) => {
188
+ // Some CLIs print their version and exit non-zero, or write it to
189
+ // stderr and nothing to stdout. Stdout with content wins over the
190
+ // exit code; nothing on stdout is a failure whatever the code said.
191
+ const out = stdout.trim();
192
+ if (out)
193
+ resolve(out);
194
+ else
195
+ reject(err ?? new Error('no output'));
196
+ });
197
+ });
198
+ }
199
+ /** Whether two detection results are the same, so nothing is broadcast. */
200
+ function same(a, b) {
201
+ if (a.size !== b.size)
202
+ return false;
203
+ for (const [id, x] of a) {
204
+ const y = b.get(id);
205
+ if (!y || JSON.stringify(x) !== JSON.stringify(y))
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ //# sourceMappingURL=detect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.js","sourceRoot":"","sources":["../../src/agents/detect.ts"],"names":[],"mappings":"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"}