@phnx-labs/agents-cli 1.20.65 → 1.20.66
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/CHANGELOG.md +12 -0
- package/README.md +119 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.d.ts +48 -0
- package/dist/commands/exec.js +65 -0
- package/dist/commands/monitors.js +8 -0
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/sessions-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +121 -4
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +28 -0
- package/dist/lib/agents.js +68 -0
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -0
- package/dist/lib/hosts/dispatch.js +20 -2
- package/dist/lib/hosts/remote-cmd.js +8 -4
- package/dist/lib/monitors/engine.js +4 -0
- package/dist/lib/monitors/sources/device.js +13 -3
- package/dist/lib/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +74 -13
- package/dist/lib/secrets/agent.d.ts +35 -0
- package/dist/lib/secrets/agent.js +114 -55
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.66
|
|
4
|
+
|
|
5
|
+
- **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`.
|
|
6
|
+
|
|
7
|
+
- **`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`.
|
|
8
|
+
|
|
9
|
+
- **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`).
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
3
15
|
## 1.20.65
|
|
4
16
|
|
|
5
17
|
- **`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
|
package/dist/commands/exec.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
|
@@ -581,6 +623,29 @@ export function registerRunCommand(program) {
|
|
|
581
623
|
// --lease, a host is persistent, so this is strictly opt-in per run.
|
|
582
624
|
let hostCopyCreds;
|
|
583
625
|
if (options.copyCreds) {
|
|
626
|
+
// Refuse to copy credentials (and the Claude OAuth token) to a host
|
|
627
|
+
// whose SSH host key isn't pinned in the managed known_hosts store:
|
|
628
|
+
// the offload transport would otherwise ride an accept-new (TOFU)
|
|
629
|
+
// connection, so a machine-in-the-middle on an unverified first
|
|
630
|
+
// connect could capture the tokens. The dispatch itself then verifies
|
|
631
|
+
// strictly against that pin (see lib/hosts/dispatch.ts) (RUSH-1767).
|
|
632
|
+
// The ship/refuse decision — pinTarget resolution, the non-device
|
|
633
|
+
// self-pin, and the final pinned check — lives in decideCopyCredsGate
|
|
634
|
+
// (unit-tested against real known_hosts fixtures).
|
|
635
|
+
const gate = decideCopyCredsGate(host);
|
|
636
|
+
if (gate.selfPinned) {
|
|
637
|
+
console.error(chalk.gray(`Pinned "${host.name}" (${gate.pinTarget}) into the managed known_hosts store.`));
|
|
638
|
+
}
|
|
639
|
+
if (!gate.allowed) {
|
|
640
|
+
console.error(chalk.red(`Refusing --copy-creds to "${host.name}": its SSH host key isn't pinned, so the ` +
|
|
641
|
+
`credentials could be exposed to a machine-in-the-middle on an unverified first connect.`));
|
|
642
|
+
console.error(chalk.gray(host.provider === 'devices'
|
|
643
|
+
? `Pin the key first by connecting once so agents records and verifies it: agents ssh ${host.name}\n` +
|
|
644
|
+
`Then re-run with --copy-creds.`
|
|
645
|
+
: `Couldn't reach "${gate.pinTarget}" to pin its key (ssh-keyscan returned nothing). ` +
|
|
646
|
+
`Make sure the host is reachable over SSH, then re-run with --copy-creds.`));
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
584
649
|
const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
|
|
585
650
|
const { confirm } = await import('@inquirer/prompts');
|
|
586
651
|
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.
|
package/dist/commands/plugins.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
611
|
-
|
|
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>;
|