@phnx-labs/agents-cli 1.20.65 → 1.20.67

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +119 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.d.ts +48 -0
  5. package/dist/commands/exec.js +71 -0
  6. package/dist/commands/monitors.js +8 -0
  7. package/dist/commands/plugins.js +28 -7
  8. package/dist/commands/sessions-browser.d.ts +82 -0
  9. package/dist/commands/sessions-browser.js +320 -0
  10. package/dist/commands/sessions.d.ts +1 -0
  11. package/dist/commands/sessions.js +121 -4
  12. package/dist/commands/share.d.ts +2 -0
  13. package/dist/commands/share.js +150 -0
  14. package/dist/commands/ssh.js +9 -1
  15. package/dist/commands/teams.js +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +28 -0
  18. package/dist/lib/agents.js +76 -0
  19. package/dist/lib/devices/connect.d.ts +18 -1
  20. package/dist/lib/devices/connect.js +10 -2
  21. package/dist/lib/devices/known-hosts.d.ts +62 -0
  22. package/dist/lib/devices/known-hosts.js +137 -0
  23. package/dist/lib/exec.d.ts +15 -0
  24. package/dist/lib/exec.js +31 -6
  25. package/dist/lib/hosts/dispatch.js +20 -2
  26. package/dist/lib/hosts/remote-cmd.js +8 -4
  27. package/dist/lib/monitors/engine.js +4 -0
  28. package/dist/lib/monitors/sources/device.js +13 -3
  29. package/dist/lib/picker.d.ts +53 -0
  30. package/dist/lib/picker.js +214 -1
  31. package/dist/lib/plugins.d.ts +31 -1
  32. package/dist/lib/plugins.js +74 -13
  33. package/dist/lib/runner.js +6 -7
  34. package/dist/lib/secrets/agent.d.ts +35 -0
  35. package/dist/lib/secrets/agent.js +114 -55
  36. package/dist/lib/secrets/filestore.d.ts +9 -0
  37. package/dist/lib/secrets/filestore.js +21 -8
  38. package/dist/lib/secrets/index.d.ts +7 -0
  39. package/dist/lib/secrets/index.js +26 -6
  40. package/dist/lib/share/capture.d.ts +29 -0
  41. package/dist/lib/share/capture.js +140 -0
  42. package/dist/lib/share/config.d.ts +35 -0
  43. package/dist/lib/share/config.js +100 -0
  44. package/dist/lib/share/og.d.ts +25 -0
  45. package/dist/lib/share/og.js +84 -0
  46. package/dist/lib/share/provision.d.ts +10 -0
  47. package/dist/lib/share/provision.js +91 -0
  48. package/dist/lib/share/publish.d.ts +57 -0
  49. package/dist/lib/share/publish.js +145 -0
  50. package/dist/lib/share/worker-template.d.ts +2 -0
  51. package/dist/lib/share/worker-template.js +82 -0
  52. package/dist/lib/shims.d.ts +13 -0
  53. package/dist/lib/shims.js +42 -2
  54. package/dist/lib/ssh-exec.d.ts +24 -0
  55. package/dist/lib/ssh-exec.js +15 -3
  56. package/dist/lib/startup/command-registry.d.ts +1 -0
  57. package/dist/lib/startup/command-registry.js +2 -0
  58. package/dist/lib/types.d.ts +20 -0
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.67
4
+
5
+ - **Interactive session browser — `agents sessions --active` and a bare `agents sessions`
6
+ now open a live, filterable picker on a TTY (RUSH-1802).** One canonical filter driven by
7
+ single keys, re-pulled across the fleet as you toggle: `s` search, `r` running-only, `c`
8
+ teams, `a` agent (cycles), `d` device (cycles), `p` this-repo↔all-dirs, `w` time window;
9
+ filters **stack** (AND together) and the active set shows in the header, with a live
10
+ preview of the highlighted row and `⏎` to resume/attach via the existing dispatch. Every
11
+ hotkey mirrors a flag, so the view is reproducible as a command — `y` copies (and
12
+ `--print-cmd` prints) the exact `ag sessions …` line the filters map to, bridging the
13
+ human picker and the agent/script flag surface. The interactive front-end is TTY-only:
14
+ `--json`, a pipe, or the new `--no-interactive` keep the existing static listing verbatim,
15
+ so scripts and headless agents are unchanged. Adds `-p` as the short form of `--project`,
16
+ `--print-cmd`, `--preview` (`agents sessions <id> --preview` prints the compact digest
17
+ without the pager), and `--no-interactive`. Built on a new async-refetch `dynamicPicker`
18
+ variant that reuses the existing render/pagination/preview machinery, the fleet SSH
19
+ fan-out, and the resume/focus path. Source: `apps/cli/src/lib/picker.ts` (`dynamicPicker`),
20
+ `apps/cli/src/commands/sessions-browser.ts` (+ `sessions-browser.test.ts`),
21
+ `apps/cli/src/commands/sessions.ts`.
22
+
23
+ - **kimi/grok headless `--mode plan` now auto-downgrades to `auto` instead of
24
+ crashing or stalling (RUSH-1810).** kimi's headless `-p` refuses to combine with
25
+ `--plan` (it hard-failed at spawn) and grok's `--permission-mode plan` silently
26
+ stalls a headless run at its ExitPlanMode gate. Both now model this honestly with
27
+ a `capabilities.headlessPlan: false` flag: a headless plan request degrades to
28
+ `auto` (kimi `-p` auto-runs; grok maps `auto`→`edit`) with a one-line stderr
29
+ warning, mirroring the graceful plan→edit degrade cursor/antigravity already get.
30
+ Interactive plan is unchanged, and claude/codex/droid/opencode keep read-only
31
+ plan headless. The same downgrade covers `agents run`, `agents teams add`
32
+ teammates, and routine jobs. Source: `apps/cli/src/lib/exec.ts`
33
+ (`resolveHeadlessMode`), `apps/cli/src/lib/runner.ts`, `apps/cli/src/lib/agents.ts`,
34
+ `apps/cli/src/lib/types.ts`.
35
+
36
+ ## 1.20.66
37
+
38
+ - **Fix (`agents monitors`, RUSH-1782 follow-up): `--watch-device` no longer silently watches the local machine on a bad name.** An unregistered or mistyped `--watch-device` name is now rejected at `add` time (same registry gate as `--device`/`--devices`), and the device source evaluator returns an explicit `device not registered` observation instead of falling back to local stats if a watched device is removed later — closing a "monitors the wrong box, silently" gap. The rate-limit firehose trip now also writes a fire record (`ok:false, error:'rate limited'`), so `agents monitors runs` reflects the auto-pause that `view`'s `last fired` already showed. Adds `sources/device.test.ts`. Source: `apps/cli/src/lib/monitors/sources/device.ts`, `apps/cli/src/commands/monitors.ts`, `apps/cli/src/lib/monitors/engine.ts`.
39
+
40
+ - **`agents monitors` — durable event-triggered watchers (RUSH-1782).** A monitor watches a SOURCE, detects a CONDITION change, and fires an ACTION — a routine whose trigger is a *watched source* instead of a *clock*, reusing the routines daemon, dispatch (`executeJobDetached`), device model, and notify path. Sources: `--watch`/`--poll` (a shell command's stdout), `--poll-http` (a URL's status+body), `--watch-file`, `--watch-device` (fleet reachability + load headroom, the first scheduler consumer of `devices/health.ts`), plus `--ws`/`--on` (push sources, accepted; delivery wired in a follow-up). Conditions: `--on-change` (default; first observation is a silent baseline), `--match <regex>` (fires once per distinct matched token), `--every`, with `--dedupe-key`. Actions: `--run <agent> --prompt` (the event is injected as `{event}`), `--routine`, `--notify`, `--webhook-out`. Pin-to-one placement: `--device <name>` names the single OWNER machine (exactly-once, v1 — no distributed lock); `--devices` is the advanced allowlist; `--run-on` offloads the action over SSH. The one genuinely new primitive is a native state-diff store (`~/.agents/.history/monitors/<name>/state.json`) that replaces the hand-rolled markdown memory files ad-hoc watchers needed. `agents monitors test <name>` is a dry-run that evaluates the source once and prints the emitted event + would-fire decision without acting. A `rateLimit: {max, per}` firehose guard auto-pauses a runaway monitor. The daemon hosts a `MonitorEngine` beside the cron scheduler, reloading on SIGHUP. Source: `apps/cli/src/lib/monitors/*` (`config.ts`, `state.ts`, `engine.ts`, `dispatch.ts`, `sources/*`), `apps/cli/src/lib/daemon.ts`, `apps/cli/src/lib/state.ts`, `apps/cli/src/commands/monitors.ts`, `apps/cli/src/lib/hosts/passthrough.ts`, `apps/cli/docs/10-monitors.md`.
41
+
42
+ - **NEW: `agents share <file>` — publish any HTML to a shareable link on your own Cloudflare R2, for ~$0 (RUSH-1791).** A one-command "publish" for agent-generated artifacts (plans, viz, reports). `agents share setup` provisions an R2 bucket + a ~30-line Worker on **your** Cloudflare (read from your `cloudflare.com` secrets bundle), enables the free `*.workers.dev` subdomain, and — if your token owns the zone — maps a custom domain like `share.agents-cli.sh`; `agents share plan.html [--slug x] [--expire 30d]` then does an authed `PUT` and prints the link. R2 has **zero egress** and a 10 GB free tier, so this is effectively free even at scale. The Worker is the ingress: uploads are bearer-gated through it (its R2 binding does the write, so the client needs no S3 keys) and **reads are public** — the link outlives the agent, since the page is stored in R2, not streamed. **Fleet/central mode**: the owner provisions one endpoint and every fleet/cloud/ephemeral agent publishes through it via the shared write token (a `share` secrets bundle) + synced `share:` config in `agents.yaml` (`agents share join` uses an existing endpoint with no provisioning). Expiry is per-object (`x-share-expires-at` metadata → the Worker 410s + lazily deletes past that instant). Source: `apps/cli/src/commands/share.ts`, `apps/cli/src/lib/share/{worker-template,provision,publish,config}.ts` (+ `Meta.share` in `apps/cli/src/lib/types.ts`).
43
+
44
+ - **`agents share` now auto-generates an OG cover so links unfurl (RUSH-1809).** Publishing an HTML page screenshots its own hero at 1200×630 and attaches it as `og:image` + `twitter:card`, so a `share.agents-cli.sh/<slug>` link previews as a rich card in Slack, iMessage, Twitter/X, and Discord. The capture is client-side (no central render service, ~$0): it reuses the CLI's browser detector (`findFirstInstalledBrowser`) and falls back to a managed Chromium in the Playwright/Puppeteer caches, skipping poor headless hosts; if nothing headless-capable is present the cover is skipped and the plain link still publishes. Pass `--no-cover` to opt out. Default slugs are now Notion-style **`<project>-<feature>-<hash>`** (the repo name scopes the link; a random tail keeps it unguessable). New: `apps/cli/src/lib/share/{capture,og}.ts`; wired through `publish.ts` and the `agents share` command.
45
+
46
+ - **SSH host-key pinning for credential-copy and `agents ssh` (Security, RUSH-1767).** `--copy-creds` now refuses to ship credentials to a `--host` whose SSH host key is not pinned, and `agents ssh` pins a host key (via `ssh-keyscan` into a managed `~/.agents/.cache/devices/known_hosts`) on first connect, resolving an ssh-config alias to its real HostName so the pin target and the strict-check target line up. Scope: this hardens the `--copy-creds` gate and the `agents ssh` pin path specifically; other SSH call sites still use OpenSSH default `~/.ssh/known_hosts` (wiring them onto the managed store is follow-up). Source: apps/cli/src/lib/devices/known-hosts.ts, apps/cli/src/lib/ssh-exec.ts, apps/cli/src/commands/exec.ts.
47
+
3
48
  ## 1.20.65
4
49
 
5
50
  - **`agents serve --control` — the authenticated anchor for the iOS/iPadOS cockpit (RUSH-1731).** The read-only `agents serve` gains an opt-in control mode: a bearer-gated HTTP surface that adds `POST /api/run` (dispatch a headless `agents run`, local or `--host <device>`, returning a server-minted session id so the run is immediately addressable) and `POST /api/session/:id/message` (steer a running agent via `agents message`), on top of the existing `GET /api/state` + SSE `/events` — which are reused verbatim, not duplicated. It adds no execution machinery: both mutations re-invoke the same CLI paths (inheriting host offload, secrets, and detached dispatch), so a run outlives the request. Every request is verified against a token whose **SHA-256 hash only** is stored on disk (`<cache>/serve/control-tokens.json`, 0600) — the raw token is shown once at first `--control` boot and never persisted; `--bind <addr>` allows reaching it from a paired phone over the tailnet (keep it on the tailnet, never public Funnel). First step of the "Fleet Cockpit" — iOS is a control plane, not a compute worker. Source: `apps/cli/src/lib/serve/control.ts`, `apps/cli/src/lib/serve/token.ts`, `apps/cli/src/lib/serve/server.ts` (extracted `handleServeGet`), `apps/cli/src/commands/serve.ts`, `+ control.test.ts` / `token.test.ts`.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <a href="https://github.com/phnx-labs/agents-cli"><img src="https://img.shields.io/badge/github-phnx--labs%2Fagents--cli-blue?style=flat-square" alt="github" /></a>
12
12
  </p>
13
13
 
14
- **The missing toolchain for CLI coding agents.** Run any agent on your existing subscription. Spawn parallel teams in isolated terminals or dispatch to the cloud for a PR. Watch live state across the fleet, nudge stalled runs, and message agents mid-flight. Schedule routines, drive browsers and Electron apps, store secrets behind Touch ID, and file tickets from a menu-bar bar — all from one CLI.
14
+ **The missing toolchain for CLI coding agents.** Run any agent on your existing subscription. Spawn parallel teams in isolated terminals or dispatch to the cloud for a PR. Watch live state across the fleet, nudge stalled runs, and message agents mid-flight. Schedule routines and set monitors that fire an agent when a source changes, drive browsers and Electron apps, store secrets behind Touch ID, and file tickets from a menu-bar bar — all from one CLI.
15
15
 
16
16
  <p align="center">
17
17
  <a href="https://github.com/anthropics/claude-code" title="Claude Code"><img src="assets/harnesses/anthropic.svg" height="32" alt="Claude Code" /></a>
@@ -60,6 +60,7 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
60
60
  - [Browser](#browser)
61
61
  - [Secrets](#secrets)
62
62
  - [Routines](#routines)
63
+ - [Monitors](#monitors)
63
64
  - [PTY](#pty)
64
65
  - [Portable setup](#portable-setup)
65
66
  - [Menu bar](#menu-bar)
@@ -125,6 +126,11 @@ Write one `AGENTS.md`. It becomes `CLAUDE.md` for Claude Code, `GEMINI.md` for G
125
126
 
126
127
  ## Run any agent
127
128
 
129
+ <p align="center">
130
+ <img src="assets/run-agent.svg" alt="agents run: one command runs any harness (claude/codex/gemini) against the project-pinned version, with an automatic rate-limit fallback chain." width="100%" />
131
+ </p>
132
+
133
+
128
134
  ```bash
129
135
  agents run claude "Find all auth vulnerabilities in src/"
130
136
  agents run codex "Fix the issues Claude found"
@@ -206,6 +212,11 @@ ACP adapters are documented for claude, codex, gemini, cursor, opencode, opencla
206
212
 
207
213
  ## Sessions across agents
208
214
 
215
+ <p align="center">
216
+ <img src="assets/sessions.svg" alt="agents sessions: search transcripts across Claude, Codex, Gemini, and OpenCode at once, plus a live --active panel showing each running session's state (working / waiting / idle)." width="100%" />
217
+ </p>
218
+
219
+
209
220
  When you run multiple agents, conversations scatter across tools. Session search brings them together.
210
221
 
211
222
  ```bash
@@ -236,6 +247,22 @@ agents sessions --active # every live run across the fleet, with stat
236
247
  agents sessions focus a1b2c3d4 # jump back into one — attach in place, or resume
237
248
  ```
238
249
 
250
+ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open the **interactive session browser** — one filter you drive with single keys, re-pulled live across the fleet:
251
+
252
+ | key | filters by | flag it mirrors |
253
+ |---|---|---|
254
+ | `s` | search text | `--query` / positional |
255
+ | `r` | running only | `--active` |
256
+ | `c` | team sessions | `--teams` |
257
+ | `a` | agent (cycles) | `-a` |
258
+ | `d` | device (cycles) | `--device` |
259
+ | `p` | this repo ↔ all dirs | `--all` |
260
+ | `w` | time window | `--since` |
261
+ | `⏎` | resume / attach | `resume` / `focus` |
262
+ | `y` | copy the equivalent command | `--print-cmd` |
263
+
264
+ Filters **stack** (they AND together), the active set shows in the header, and the highlighted row previews below. Because every hotkey has a flag, the view you build by hand is a real command: press `y` (or run `--print-cmd`) to get the exact `ag sessions …` line — explore interactively, hand the line to an agent. Piped output, `--json`, or `--no-interactive` keep the plain listing for scripts. Peek without opening the pager with `agents sessions <id> --preview`.
265
+
239
266
  Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), or `idle`, alongside badges for the PR it opened, the worktree it sits in, and the ticket it's working. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
240
267
 
241
268
  Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work, and the last test verdict -- the signals to reload a task in seconds.
@@ -298,6 +325,11 @@ agents watchdog --watch # daemon loop: a tick every --interval
298
325
 
299
326
  ## Sync the fleet
300
327
 
328
+ <p align="center">
329
+ <img src="assets/fleet-sync.svg" alt="agents apply: reconcile every device to one profile from agents.yaml — install missing agents, sync config, and propagate logins across the fleet." width="100%" />
330
+ </p>
331
+
332
+
301
333
  One machine is set up the way you like it. Make every other machine match -- same agents installed, same config, logins seeded -- in one command.
302
334
 
303
335
  ```yaml
@@ -366,6 +398,11 @@ Profile YAML has no secrets -- safe to `agents repo push` to a shared repo. `age
366
398
 
367
399
  ## Run on your own machines
368
400
 
401
+ <p align="center">
402
+ <img src="assets/hosts.svg" alt="agents hosts: dispatch agents run and config commands to another machine over plain SSH (no daemon); the Tailscale fleet is auto-discovered." width="100%" />
403
+ </p>
404
+
405
+
369
406
  Dispatch any read-only or config command -- and `agents run` itself -- to another machine over SSH. No daemon.
370
407
 
371
408
  ```bash
@@ -417,6 +454,11 @@ Every `--host` command rides one multiplexed SSH engine, tuned for driving a fle
417
454
 
418
455
  ## Teams
419
456
 
457
+ <p align="center">
458
+ <img src="assets/teams.svg" alt="agents teams: parallel agents in dependency order, each detached in its own worktree with boundary contracts." width="100%" />
459
+ </p>
460
+
461
+
420
462
  ```bash
421
463
  agents teams create auth-feature
422
464
 
@@ -469,6 +511,11 @@ Auto-routes each `--agent` to its native cloud, or pin the backend with `--provi
469
511
 
470
512
  ## Workflows
471
513
 
514
+ <p align="center">
515
+ <img src="assets/workflows.svg" alt="agents workflows: bundle an orchestrator prompt with optional subagents, skills, and plugins into a named, reusable pipeline invoked as one agent." width="100%" />
516
+ </p>
517
+
518
+
472
519
  Bundle an orchestrator prompt with optional subagents, skills, and plugins into a named, reusable pipeline. One bundle, one invocation.
473
520
 
474
521
  ```bash
@@ -520,6 +567,11 @@ Resolution is project > user > system: a `<repo>/.agents/workflows/<name>/` over
520
567
 
521
568
  ## Plugins
522
569
 
570
+ <p align="center">
571
+ <img src="assets/plugins.svg" alt="agents plugins: bundle skills, commands, hooks, and MCP servers under one manifest, mirrored into every installed agent version automatically." width="100%" />
572
+ </p>
573
+
574
+
523
575
  Bundle skills, commands, hooks, MCP servers, settings, and permissions under a single manifest. One source dir at `~/.agents/plugins/<name>/`, mirrored into every installed Claude / OpenClaw version automatically.
524
576
 
525
577
  ```bash
@@ -570,6 +622,10 @@ Plugins live in the user repo (`~/.agents/plugins/`), not inside any single vers
570
622
 
571
623
  ## Browser
572
624
 
625
+ <p align="center">
626
+ <img src="assets/browser.svg" alt="agents browser drives your real, already-installed Chrome over CDP — the CLI issues start / refs / click / type / screenshot; the browser exposes numbered element refs and returns a token-efficient screenshot. Same fingerprint, same IP, so sites can't detect automation — it works where Playwright gets blocked." width="100%" />
627
+ </p>
628
+
573
629
  Give agents access to a real browser — no relay extension, no cloud service, no Playwright getting blocked.
574
630
 
575
631
  ```bash
@@ -742,8 +798,70 @@ Jobs run sandboxed -- agents only see directories and tools you explicitly allow
742
798
 
743
799
  ---
744
800
 
801
+ ## Monitors
802
+
803
+ <p align="center">
804
+ <img src="assets/monitors.svg" alt="agents monitors: a watched source (poll a command, an HTTP endpoint, a file, or a fleet device) flows into a condition (changed? matched? deduped by a native state store) that fires an action — run an agent with the event in its prompt, kick a routine, or notify. Pin the owner device for exactly-once." width="100%" />
805
+ </p>
806
+
807
+ ```bash
808
+ # Routines fire on a clock. Monitors fire on a change: watch a source, and when
809
+ # it flips, spawn an agent, kick a routine, or notify. The cross-agent layer --
810
+ # agents watching sources (including the fleet and other agents) and reacting.
811
+
812
+ # CI goes red -> a Claude agent triages it (poll a command, diff, match a pattern)
813
+ agents monitors add ci-red \
814
+ --poll 'gh pr checks 1249 --json name,bucket' 30s --match fail \
815
+ --run claude --prompt 'CI failed: {event}. Diagnose and fix.' \
816
+ --device yosemite-s0
817
+
818
+ # A fleet box goes unreachable or overloaded -> notify (watch the fleet itself)
819
+ agents monitors add box-down --watch-device mac-mini --on-change --notify telegram
820
+
821
+ # Poll an endpoint every 8h; fire once when the body flips to "issued"
822
+ agents monitors add cert-issued \
823
+ --poll-http 'https://secure.ssl.com/.../order' 8h --match issued --notify telegram
824
+
825
+ agents monitors test ci-red # Dry-run: evaluate the source once, show what it would fire -- no action
826
+ agents monitors list # Every monitor: source, owner device, last fired
827
+ ```
828
+
829
+ Sources: a command's stdout (`--watch` / `--poll`), an HTTP endpoint (`--poll-http`), a file (`--watch-file`), or a fleet device's reachability + load (`--watch-device`). Push sources -- a signed webhook (`--on`) and a WebSocket (`--ws`) -- are accepted today and delivered through a receiver wired in a follow-up. Conditions: fire on any change (`--on-change`), on a regex (`--match`), or `--every` tick -- deduped by a native state store, so a monitor stays silent until something *actually* changes. Actions: `--run <agent>` (the event is injected into the prompt as `{event}`), `--routine`, `--notify`, or `--webhook-out`. Pin a monitor to one owner device with `--device` (exactly-once), or offload the action elsewhere with `--run-on`. Runs in the routines daemon; `agents monitors pause` / `resume` any time.
830
+
831
+ ---
832
+
833
+ ## Share
834
+
835
+ ```bash
836
+ # Publish an HTML artifact to a public link on your own Cloudflare R2 (~$0).
837
+ agents share setup # once: provision bucket + Worker on your CF
838
+ agents share plan.html --slug fleet --expire 30d # → https://<base>/fleet
839
+ agents share status # show the endpoint
840
+ ```
841
+
842
+ `agents share` closes the loop: an agent makes work (a plan, a viz, a report),
843
+ publishes it, and you open the link to see it. `setup` reads a Cloudflare API token
844
+ from your `cloudflare.com` secrets bundle (or `--token`), creates an R2 bucket, uploads
845
+ a tiny Worker, and enables the free `*.workers.dev` subdomain (or maps `--domain
846
+ share.example.com` when the token owns the zone). Writes are bearer-gated **through**
847
+ the Worker (its R2 binding does the put, so the client needs no S3 keys); reads are
848
+ **public**, so a link outlives the agent. R2 has zero egress + a 10 GB free tier, so
849
+ this is effectively free.
850
+
851
+ **Fleet mode:** provision one endpoint, then every fleet / cloud / ephemeral agent
852
+ publishes through it with a shared write token — `agents share join <baseUrl>` uses an
853
+ existing endpoint with no provisioning. `--expire 30d|12h|<date>` auto-expires a link.
854
+ See [docs/share.md](apps/cli/docs/share.md).
855
+
856
+ ---
857
+
745
858
  ## PTY
746
859
 
860
+ <p align="center">
861
+ <img src="assets/pty.svg" alt="agents pty: give an agent a real terminal for REPLs and TUIs; a sidecar server holds sessions alive between CLI calls." width="100%" />
862
+ </p>
863
+
864
+
747
865
  ```bash
748
866
  # Give agents a real terminal for REPLs, TUIs, interactive programs.
749
867
  SID=$(agents pty start)
package/dist/bin/agents CHANGED
Binary file
@@ -7,6 +7,54 @@
7
7
  */
8
8
  import { type Command } from 'commander';
9
9
  import type { ExecEffort } from '../lib/exec.js';
10
+ import { type SshGResult } from '../lib/hosts/ssh-config.js';
11
+ /** The host descriptor fields the `--copy-creds` security gate reads. */
12
+ export interface CopyCredsGateHost {
13
+ name: string;
14
+ /** Concrete SSH target for inline/provider hosts; unset for ssh-config aliases. */
15
+ address?: string;
16
+ /** `devices` = registered Tailscale fleet host; `local` = ssh-config/inline. */
17
+ provider?: string;
18
+ }
19
+ /** Outcome of the `--copy-creds` security gate (RUSH-1767). */
20
+ export interface CopyCredsGateDecision {
21
+ /** Ship credentials? True only when the host key is pinned in the managed store. */
22
+ allowed: boolean;
23
+ /** The address whose key is checked/pinned — an ssh-config alias resolved to its real HostName. */
24
+ pinTarget: string;
25
+ /** True when the non-device self-pin path pinned the target during this call. */
26
+ selfPinned: boolean;
27
+ }
28
+ /** Injectable network seams so the gate decision is testable without ssh/keyscan. */
29
+ export interface CopyCredsGateDeps {
30
+ /** Managed known_hosts store file (defaults to the real cache path). */
31
+ file?: string;
32
+ /** Resolve an ssh-config alias to its effective HostName/Port (defaults to real `ssh -G`). */
33
+ resolve?: (name: string) => SshGResult | undefined;
34
+ /** Self-pin `target` via ssh-keyscan; returns whether it's pinned afterward (defaults to real `pinHostKey`). */
35
+ selfPin?: (target: string, port: number | undefined, file: string) => boolean;
36
+ }
37
+ /**
38
+ * Decide whether `--copy-creds` may ship credentials (and the Claude OAuth
39
+ * token) to `host` (RUSH-1767). Credentials ship only to a host whose SSH host
40
+ * key is pinned in the managed known_hosts store, so the offload never rides an
41
+ * accept-new (TOFU) connection a machine-in-the-middle could intercept.
42
+ *
43
+ * Resolution:
44
+ * - The checked target is the host's concrete `address`, else its ssh-config
45
+ * HostName (`ssh -G`), else its name — so an alias is pinned/verified against
46
+ * the SAME real host the strict dispatch later connects to.
47
+ * - An unpinned NON-device (a bare `~/.ssh/config` `Host` alias or literal,
48
+ * which `agents ssh <name>` can't reach — "Unknown device") is self-pinned in
49
+ * place via ssh-keyscan. A registered device is left unpinned here — it earns
50
+ * its pin through the normal `agents ssh <name>` accept-new connect, so the
51
+ * caller steers there instead.
52
+ *
53
+ * The network seams (`resolve`, `selfPin`) default to the real ssh -G /
54
+ * ssh-keyscan implementations and are injectable so the ship/refuse decision is
55
+ * unit-testable against real known_hosts fixtures with no network.
56
+ */
57
+ export declare function decideCopyCredsGate(host: CopyCredsGateHost, deps?: CopyCredsGateDeps): CopyCredsGateDecision;
10
58
  /**
11
59
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
12
60
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source
@@ -12,6 +12,8 @@ import { parseLoopInterval } from '../lib/loop.js';
12
12
  import { AGENTS } from '../lib/agents.js';
13
13
  import { recordDispatchedRun } from '../lib/audit/log.js';
14
14
  import { warnUnpushedWork, shouldWarnUnpushed } from '../lib/warn-unpushed.js';
15
+ import { isHostPinned, pinHostKey, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
16
+ import { sshResolve } from '../lib/hosts/ssh-config.js';
15
17
  import * as fs from 'fs';
16
18
  import * as path from 'path';
17
19
  import * as os from 'os';
@@ -27,6 +29,46 @@ function formatRotationBanner(result, verb = 'balanced') {
27
29
  const ratio = `${healthy.length} of ${healthy.length + excluded.length} healthy`;
28
30
  return `[agents] ${verb} picked ${label} (${ratio})`;
29
31
  }
32
+ /**
33
+ * Decide whether `--copy-creds` may ship credentials (and the Claude OAuth
34
+ * token) to `host` (RUSH-1767). Credentials ship only to a host whose SSH host
35
+ * key is pinned in the managed known_hosts store, so the offload never rides an
36
+ * accept-new (TOFU) connection a machine-in-the-middle could intercept.
37
+ *
38
+ * Resolution:
39
+ * - The checked target is the host's concrete `address`, else its ssh-config
40
+ * HostName (`ssh -G`), else its name — so an alias is pinned/verified against
41
+ * the SAME real host the strict dispatch later connects to.
42
+ * - An unpinned NON-device (a bare `~/.ssh/config` `Host` alias or literal,
43
+ * which `agents ssh <name>` can't reach — "Unknown device") is self-pinned in
44
+ * place via ssh-keyscan. A registered device is left unpinned here — it earns
45
+ * its pin through the normal `agents ssh <name>` accept-new connect, so the
46
+ * caller steers there instead.
47
+ *
48
+ * The network seams (`resolve`, `selfPin`) default to the real ssh -G /
49
+ * ssh-keyscan implementations and are injectable so the ship/refuse decision is
50
+ * unit-testable against real known_hosts fixtures with no network.
51
+ */
52
+ export function decideCopyCredsGate(host, deps = {}) {
53
+ const file = deps.file ?? managedKnownHostsPath();
54
+ const resolve = deps.resolve ?? sshResolve;
55
+ const selfPin = deps.selfPin ?? ((target, port, f) => pinHostKey(target, { file: f, ...(port ? { port } : {}) }).pinned);
56
+ // For an ssh-config host `host.address` is unset; ssh -G gives the real
57
+ // HostName (and Port) the strict dispatch will verify against.
58
+ const cfg = host.address ? undefined : resolve(host.name);
59
+ const pinTarget = host.address ?? cfg?.hostname ?? host.name;
60
+ // Not pinned yet? A registered device earns its pin through the normal
61
+ // accept-new connect (`agents ssh <name>` / the fleet sweep), so leave it and
62
+ // let the caller steer there. A bare ssh-config alias / literal is NOT a
63
+ // registered device — that flow can never pin it — so pin it right here.
64
+ let selfPinned = false;
65
+ if (!isHostPinned(pinTarget, file) && host.provider !== 'devices') {
66
+ const cfgPort = cfg?.port;
67
+ const port = cfgPort && cfgPort !== '22' ? Number(cfgPort) : undefined;
68
+ selfPinned = selfPin(pinTarget, port, file);
69
+ }
70
+ return { allowed: isHostPinned(pinTarget, file), pinTarget, selfPinned };
71
+ }
30
72
  /**
31
73
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
32
74
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source
@@ -286,6 +328,12 @@ export function registerRunCommand(program) {
286
328
  skip bypass every permission prompt (dangerously-skip-permissions)
287
329
  Legacy 'full' is silently rewritten to 'skip'.
288
330
 
331
+ Headless plan support (a prompt makes the run headless):
332
+ plan works headless on claude, codex, droid, opencode.
333
+ kimi, grok, cursor, antigravity have no headless plan mode — a headless
334
+ --mode plan auto-downgrades to --mode auto (with a stderr warning).
335
+ Interactive plan (omit the prompt) works everywhere it is listed.
336
+
289
337
  Run strategy (set via --strategy or run.<agent>.strategy in agents.yaml):
290
338
  pinned use the workspace/global pinned version
291
339
  available use pinned if it can run right now; otherwise switch to another signed-in version
@@ -581,6 +629,29 @@ export function registerRunCommand(program) {
581
629
  // --lease, a host is persistent, so this is strictly opt-in per run.
582
630
  let hostCopyCreds;
583
631
  if (options.copyCreds) {
632
+ // Refuse to copy credentials (and the Claude OAuth token) to a host
633
+ // whose SSH host key isn't pinned in the managed known_hosts store:
634
+ // the offload transport would otherwise ride an accept-new (TOFU)
635
+ // connection, so a machine-in-the-middle on an unverified first
636
+ // connect could capture the tokens. The dispatch itself then verifies
637
+ // strictly against that pin (see lib/hosts/dispatch.ts) (RUSH-1767).
638
+ // The ship/refuse decision — pinTarget resolution, the non-device
639
+ // self-pin, and the final pinned check — lives in decideCopyCredsGate
640
+ // (unit-tested against real known_hosts fixtures).
641
+ const gate = decideCopyCredsGate(host);
642
+ if (gate.selfPinned) {
643
+ console.error(chalk.gray(`Pinned "${host.name}" (${gate.pinTarget}) into the managed known_hosts store.`));
644
+ }
645
+ if (!gate.allowed) {
646
+ console.error(chalk.red(`Refusing --copy-creds to "${host.name}": its SSH host key isn't pinned, so the ` +
647
+ `credentials could be exposed to a machine-in-the-middle on an unverified first connect.`));
648
+ console.error(chalk.gray(host.provider === 'devices'
649
+ ? `Pin the key first by connecting once so agents records and verifies it: agents ssh ${host.name}\n` +
650
+ `Then re-run with --copy-creds.`
651
+ : `Couldn't reach "${gate.pinTarget}" to pin its key (ssh-keyscan returned nothing). ` +
652
+ `Make sure the host is reachable over SSH, then re-run with --copy-creds.`));
653
+ process.exit(1);
654
+ }
584
655
  const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
585
656
  const { confirm } = await import('@inquirer/prompts');
586
657
  const detected = await detectSignedInRuntimes();
@@ -118,6 +118,8 @@ function buildSource(options) {
118
118
  if (options.watchFile)
119
119
  chosen.push({ type: 'file', source: { type: 'file', path: options.watchFile } });
120
120
  if (options.watchDevice) {
121
+ // Name is validated against the registry in the async add handler (fail fast,
122
+ // same gate as --device/--devices) — buildSource is sync so it can't await.
121
123
  chosen.push({ type: 'device', source: { type: 'device', device: options.watchDevice } });
122
124
  }
123
125
  if (options.on) {
@@ -343,6 +345,12 @@ export function registerMonitorsCommands(program) {
343
345
  process.exit(1);
344
346
  }
345
347
  const source = buildSource(options);
348
+ // A --watch-device source name must resolve to a registered fleet member —
349
+ // validate it (fail fast with the registered list) so a typo/removed device
350
+ // can't silently watch the local machine (same gate as --device/--devices).
351
+ if (source.type === 'device' && source.device) {
352
+ source.device = await validateDevice(source.device);
353
+ }
346
354
  const condition = buildCondition(options);
347
355
  const action = buildAction(options);
348
356
  // Placement.
@@ -573,6 +573,7 @@ Examples:
573
573
  pluginsCmd
574
574
  .command('update [name]')
575
575
  .description('Re-pull a plugin from its original source and re-sync to all versions')
576
+ .option('--allow-exec-surfaces', 'Consent to an update that introduces new executable surfaces (hooks/, bin/, scripts/, .mcp.json, settings.json, permissions/)')
576
577
  .addHelpText('after', `
577
578
  Examples:
578
579
  # Update a specific plugin
@@ -580,8 +581,11 @@ Examples:
580
581
 
581
582
  # Update all plugins
582
583
  agents plugins update
584
+
585
+ # Trust an update that adds new executable surfaces
586
+ agents plugins update rush-toolkit --allow-exec-surfaces
583
587
  `)
584
- .action(async (nameArg) => {
588
+ .action(async (nameArg, options) => {
585
589
  const plugins = nameArg ? [getPlugin(nameArg)].filter(Boolean) : discoverPlugins();
586
590
  if (nameArg && plugins.length === 0) {
587
591
  console.log(chalk.red(`Plugin '${nameArg}' not found`));
@@ -591,24 +595,41 @@ Examples:
591
595
  console.log(chalk.gray('No plugins installed.'));
592
596
  return;
593
597
  }
598
+ const allowExec = options.allowExecSurfaces === true;
594
599
  for (const plugin of plugins) {
595
600
  process.stdout.write(`Updating ${plugin.name}... `);
596
- const result = await updatePlugin(plugin.name);
601
+ const result = await updatePlugin(plugin.name, { allowExecSurfaces: allowExec });
597
602
  if (!result.success) {
598
- console.log(chalk.red(`failed — ${result.error || 'unknown error'}`));
603
+ if (result.blockedByExecSurfaces) {
604
+ // Security (RUSH-1757): the update introduced new executable surfaces.
605
+ // Refuse without renewed consent; the last-good content stays in place.
606
+ console.log(chalk.yellow('skipped'));
607
+ console.log(chalk.yellow(` Update introduces new executable surfaces: ${(result.newExecSurfaces || []).join(', ')}`));
608
+ console.log(chalk.gray(' Kept the currently-installed revision. Re-run with --allow-exec-surfaces if you trust the source.'));
609
+ }
610
+ else {
611
+ console.log(chalk.red(`failed — ${result.error || 'unknown error'}`));
612
+ }
599
613
  continue;
600
614
  }
601
615
  console.log(chalk.green('done'));
602
- // Re-sync to all supported installed versions
616
+ // Reload the plugin so the re-sync reads the freshly-applied revision.
617
+ const updated = getPlugin(plugin.name) ?? plugin;
618
+ // Re-sync to all supported installed versions. When the applied revision
619
+ // carries executable surfaces, only enable them if the user consented on
620
+ // this update (--allow-exec-surfaces); otherwise the benign content syncs
621
+ // but stays disabled, matching the install-time trust gate.
603
622
  for (const agentId of capableAgents('plugins')) {
604
- if (!pluginSupportsAgent(plugin, agentId))
623
+ if (!pluginSupportsAgent(updated, agentId))
605
624
  continue;
606
625
  const versions = listInstalledVersions(agentId);
607
626
  const defaultVer = getGlobalDefault(agentId);
608
627
  const targetVersions = defaultVer ? [defaultVer] : versions.slice(-1);
609
628
  for (const version of targetVersions) {
610
- const syncResult = syncResourcesToVersion(agentId, version, { plugins: [plugin.name] });
611
- if (syncResult.plugins.length > 0) {
629
+ const didSync = allowExec
630
+ ? syncPluginToVersion(updated, agentId, getVersionHomePath(agentId, version), { allowExecSurfaces: true, version }).success
631
+ : syncResourcesToVersion(agentId, version, { plugins: [updated.name] }).plugins.length > 0;
632
+ if (didSync) {
612
633
  console.log(chalk.gray(` Re-synced to ${agentLabel(agentId)}@${version}`));
613
634
  }
614
635
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Interactive fleet-wide session browser — the human front-end of `agents sessions`.
3
+ *
4
+ * One canonical filter state (device / agent / project / window / running / teams),
5
+ * driven by single-key hotkeys, re-pulling live over the same fleet fan-out the flag
6
+ * surface uses. The identical state is expressible as flags (the agent front-end);
7
+ * `y` / `--print-cmd` round-trips a hand-built view into a copy-pasteable command.
8
+ *
9
+ * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
+ * preview, resume dispatch) is reused from the existing sessions plumbing.
11
+ */
12
+ import type { SessionMeta } from '../lib/session/types.js';
13
+ /**
14
+ * The single canonical filter state. Every field has a flag equivalent, so the
15
+ * same view is reachable interactively (hotkeys) or from the command line (flags).
16
+ */
17
+ export interface BrowserFilter {
18
+ /** running-only — the `R` key / `--active`. */
19
+ running: boolean;
20
+ /** include team-spawned sessions — the `C` key / `--teams`. */
21
+ teams: boolean;
22
+ /** filter to one agent, or all — the `A` key / `-a`. */
23
+ agent?: string;
24
+ /** filter to one machine, or all — the `D` key / `--device`. */
25
+ device?: string;
26
+ /** this-repo subtree vs every directory — the `P` key / `--all`. */
27
+ projectScope: 'repo' | 'all';
28
+ /** time window (undefined = all time) — the `W` key / `--since`. */
29
+ window?: string;
30
+ }
31
+ /** Return the next value in `[undefined, ...options]`, wrapping. */
32
+ export declare function cycle(current: string | undefined, options: string[]): string | undefined;
33
+ export declare function cycleWindow(current: string | undefined): string | undefined;
34
+ /**
35
+ * Cheap client-side match for the `S` search — a plain substring test over a
36
+ * row's visible fields. Deliberately NOT the FTS `filterSessionsByQuery`: that
37
+ * runs a content-index scan per call, which is fine once over a pool but a
38
+ * CPU sink when a picker calls it per-row on every keystroke.
39
+ */
40
+ export declare function sessionMatchesQuery(s: SessionMeta, query: string): boolean;
41
+ /**
42
+ * The canonical `ag sessions …` command for a filter state (+ optional search) —
43
+ * the agent-facing twin of the interactive view. Shared by the `y` hotkey and
44
+ * `--print-cmd`.
45
+ */
46
+ export declare function browserFilterToArgv(f: BrowserFilter, query?: string): string[];
47
+ /** Normalize a `--host`/`--device` token (`alias`, `user@host`, `host.domain`) to
48
+ * the canonical machine id the rows carry in `.machine`, so a flag seed matches
49
+ * (the `d` hotkey already cycles canonical ids). Mirrors sessions.ts `hostToken`. */
50
+ export declare function normalizeDeviceSeed(host: string | undefined): string | undefined;
51
+ /**
52
+ * The initial filter for the `--active` browser: fleet-wide (matches the static
53
+ * `renderActiveSessions`, which has no project scoping — the `p` hotkey narrows to
54
+ * this repo), running-only, with the device seed normalized and `--since` seeding
55
+ * the window. Pure, so the routing call site is unit-testable.
56
+ */
57
+ export declare function activeBrowserSeed(opts: {
58
+ teams?: boolean;
59
+ agent?: string;
60
+ host?: string[];
61
+ since?: string;
62
+ }): Partial<BrowserFilter>;
63
+ /**
64
+ * The initial filter for the bare interactive listing: current-repo subtree by
65
+ * default (matches the static overview's cwd scoping), `--all` widens to every
66
+ * directory, `--since` seeds the window.
67
+ */
68
+ export declare function bareBrowserSeed(opts: {
69
+ teams?: boolean;
70
+ agent?: string;
71
+ all?: boolean;
72
+ since?: string;
73
+ }): Partial<BrowserFilter>;
74
+ /**
75
+ * Launch the interactive session browser. `initial` seeds the filter (e.g.
76
+ * `{ running: true }` for `--active`). Resolves after the user resumes a session
77
+ * or cancels — the picked row is dispatched through the shared resume/focus path.
78
+ */
79
+ export declare function runSessionBrowser(initial?: Partial<BrowserFilter>, opts?: {
80
+ local?: boolean;
81
+ hosts?: string[];
82
+ }): Promise<void>;