@phnx-labs/agents-cli 1.20.61 → 1.20.63

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/CHANGELOG.md +57 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +51 -1
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +100 -28
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/commands.js +29 -0
  32. package/dist/lib/convert.d.ts +11 -0
  33. package/dist/lib/convert.js +22 -0
  34. package/dist/lib/daemon.js +2 -0
  35. package/dist/lib/devices/fleet.d.ts +62 -0
  36. package/dist/lib/devices/fleet.js +128 -0
  37. package/dist/lib/doctor-diff.js +17 -1
  38. package/dist/lib/funnel.d.ts +5 -0
  39. package/dist/lib/funnel.js +23 -0
  40. package/dist/lib/git.d.ts +21 -5
  41. package/dist/lib/git.js +64 -14
  42. package/dist/lib/goose-commands.d.ts +41 -0
  43. package/dist/lib/goose-commands.js +176 -0
  44. package/dist/lib/hooks.js +134 -0
  45. package/dist/lib/hosts/credentials.d.ts +28 -0
  46. package/dist/lib/hosts/credentials.js +48 -0
  47. package/dist/lib/hosts/dispatch.d.ts +25 -0
  48. package/dist/lib/hosts/dispatch.js +68 -2
  49. package/dist/lib/hosts/passthrough.d.ts +13 -10
  50. package/dist/lib/hosts/passthrough.js +119 -29
  51. package/dist/lib/mailbox-gc.js +4 -16
  52. package/dist/lib/migrate.d.ts +12 -0
  53. package/dist/lib/migrate.js +55 -1
  54. package/dist/lib/permissions.d.ts +15 -0
  55. package/dist/lib/permissions.js +99 -0
  56. package/dist/lib/plugins.d.ts +20 -0
  57. package/dist/lib/plugins.js +118 -0
  58. package/dist/lib/resources/permissions.js +2 -0
  59. package/dist/lib/routines.d.ts +29 -10
  60. package/dist/lib/routines.js +47 -15
  61. package/dist/lib/session/active.d.ts +8 -1
  62. package/dist/lib/session/active.js +1 -0
  63. package/dist/lib/session/parse.js +12 -0
  64. package/dist/lib/session/state.d.ts +33 -0
  65. package/dist/lib/session/state.js +40 -0
  66. package/dist/lib/session/sync/agents.d.ts +2 -0
  67. package/dist/lib/session/sync/agents.js +39 -1
  68. package/dist/lib/session/sync/config.d.ts +8 -0
  69. package/dist/lib/session/sync/config.js +6 -1
  70. package/dist/lib/session/sync/provision.d.ts +49 -0
  71. package/dist/lib/session/sync/provision.js +91 -0
  72. package/dist/lib/session/sync/sync.d.ts +3 -0
  73. package/dist/lib/session/sync/sync.js +26 -6
  74. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  75. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  76. package/dist/lib/staleness/detectors/permissions.js +23 -0
  77. package/dist/lib/staleness/detectors/subagents.js +36 -0
  78. package/dist/lib/staleness/writers/commands.js +7 -0
  79. package/dist/lib/staleness/writers/hooks.js +1 -1
  80. package/dist/lib/staleness/writers/subagents.js +22 -3
  81. package/dist/lib/startup/command-registry.d.ts +2 -0
  82. package/dist/lib/startup/command-registry.js +11 -0
  83. package/dist/lib/state.d.ts +10 -2
  84. package/dist/lib/state.js +14 -2
  85. package/dist/lib/subagents.d.ts +32 -0
  86. package/dist/lib/subagents.js +238 -0
  87. package/dist/lib/triggers/webhook.d.ts +70 -27
  88. package/dist/lib/triggers/webhook.js +264 -43
  89. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.63
6
+
7
+ - **Built-in routines: the daemon now fires routines shipped in the system repo.** Routine discovery (`listJobs`/`readJob`) unions a new system layer — `~/.agents/.system/routines/*.yml` (shipped via `gh:phnx-labs/.agents-system`, which every install pulls at `agents setup`) — under the existing project and user layers. Ordering is project > user > system with first-seen-wins, so a routine shipped as a built-in fires for every install, while a user routine of the same name overrides it and a user copy with `enabled: false` disables it. The daemon (which loads with no `cwd`) sees user + system routines; `writeJob` still only ever writes to the user layer, so built-ins are never mutated in place. This is what lets a routine like `check-updates` ship to all users centrally. Source: `apps/cli/src/lib/state.ts` (`getSystemRoutinesDir`), `apps/cli/src/lib/routines.ts`, `apps/cli/src/lib/routines.test.ts`.
8
+
9
+ - **`--host` / `--device` work across virtually all first-class subcommand groups (RUSH-1691).** Previously only a handful of groups accepted the flags; `agents repos list --host yosemite-s0` (and the same with `--device`) died with commander's raw `unknown option`. Remote routing now covers `repos`/`repo`, status/inspect groups, config/resource groups (`plugins`, `skills`, `hooks`, `sync`, …), `teams`, `routines`, and more via the central `maybeRunOnHost` allowlist. Commands with their own richer host handling (`run`, `sessions`, `feed`, `computer`, `secrets`, `logs`) still fall through to their actions. Groups with no remote semantics reject the flag with a clear message instead of `unknown option`. Self-host targets strip the routing flags before the local command parses so fall-through never trips an unregistered option. Source: `apps/cli/src/lib/hosts/passthrough.ts`, `apps/cli/src/commands/repo.ts`, `apps/cli/docs/{00-concepts,hosts}.md`.
10
+ - **Signed webhook ingress for routines via Tailscale Funnel (RUSH-1456, RUSH-1459, RUSH-1460, RUSH-1461).**
11
+ Routine triggers now understand both GitHub and Linear event sources, including
12
+ Linear action/team/label filters. `agents webhook serve --secrets-bundle <name>`
13
+ exposes signed localhost endpoints at `/hooks/github` and `/hooks/linear` with
14
+ raw-body HMAC verification, Linear timestamp checks, duplicate delivery
15
+ suppression, and rate limiting. `agents funnel status/up` wraps the allowed
16
+ Tailscale Funnel ports through the existing SSH/device path so a webhook
17
+ receiver can be exposed without hand-written SSH commands. Source:
18
+ `apps/cli/src/lib/routines.ts`, `apps/cli/src/lib/triggers/webhook.ts`,
19
+ `apps/cli/src/commands/routines.ts`, `apps/cli/src/commands/webhook.ts`,
20
+ `apps/cli/src/commands/funnel.ts`, `apps/cli/src/lib/funnel.ts`.
21
+ - **Guided session-sync provisioning in `agents setup` and `agents sessions sync --setup` (RUSH-1468).** Joining a machine to the cross-machine session-sync fabric no longer requires hand-running four `agents secrets add r2.backups …` commands. A new interactive step mints the `r2.backups` bundle (R2 account/bucket/access-key/secret + a generated `R2_SYNC_ENC_KEY`), probes read+write connectivity with a throwaway object, and opts the machine into the `session-sync` beta on success. The first machine mints and prints the shared encryption key; every other machine pastes it so the whole fabric shares one key (an existing key is reused, never overwritten — overwriting would orphan peers' encrypted transcripts). `agents setup` offers it opt-in (default No, never blocks setup); `agents sessions sync --setup` runs it explicitly and can re-show the shared key. Source: `apps/cli/src/lib/session/sync/provision.ts`, `apps/cli/src/lib/session/sync/provision.test.ts`, `apps/cli/src/commands/sync-provision.ts`, `apps/cli/src/commands/setup.ts`, `apps/cli/src/commands/sessions-sync.ts`.
22
+ - **`agents fleet` alias + fleet-wide rollout (RUSH-1632).** `fleet` is an alias
23
+ for `devices`. New subcommands `update [version]` and `run <cmd…>` roll out
24
+ across every online device with a per-device result table. Source:
25
+ `apps/cli/src/commands/ssh.ts`, `apps/cli/src/lib/devices/fleet.ts`.
26
+ - **`agents hosts stop <id>` (alias `kill`) terminates a detached host run from the origin machine (RUSH-1360).**
27
+ Sends SIGTERM to the remote process group, writes exit `143` only when a live
28
+ group was signaled (or no `.exit` existed), and keeps the remote log for
29
+ `agents hosts logs <id>`. Source: `apps/cli/src/lib/hosts/dispatch.ts`,
30
+ `apps/cli/src/commands/hosts.ts`.
31
+ - **Fix: mailbox GC actually archives expired messages on live boxes (RUSH-1611).**
32
+ The live-box branch of `gcMailbox` only incremented `messagesDroppedExpired`
33
+ without moving the file to `consumed/`, so `agents feed --dispatch` could report
34
+ drops while leaving expired messages in `inbox/`/`processing/`. GC now reuses
35
+ `sweepExpired` (the same path as drain/peek). Source: `apps/cli/src/lib/mailbox-gc.ts`.
36
+ - **Fix: Antigravity sign-in detection on Linux when the OAuth grant lives in Secret Service (RUSH-1329).** `agy` uses the Go keyring library, which prefers libsecret (gnome-keyring) over the file fallback whenever a Secret Service daemon is running — so `~/.gemini/antigravity-cli/antigravity-oauth-token` may be absent even when the user is signed in. After the file check, `getAccountInfo` now probes `secret-tool lookup service gemini username antigravity` (exit 0 = present; stdout discarded), mirroring the macOS `security find-generic-password` probe from #506. Missing `secret-tool`, locked collections, and timeouts all read as signed out. Opt out with `AGENTS_NO_KEYCHAIN_PROBE=1`. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/agents.test.ts`.
37
+ - **Client-side (zero-knowledge) encryption of session transcripts before R2 upload (RUSH-1463).** Transcripts carry secrets, tokens, and absolute file paths; R2's server-side encryption uses Cloudflare's key, so anyone with bucket-read access (or Cloudflare) could read them as plaintext NDJSON. `agents sessions sync` now seals each transcript BODY client-side with AES-256-GCM before upload and decrypts on pull, so R2 only ever stores ciphertext. The 32-byte key is a new `R2_SYNC_ENC_KEY` in the `r2.backups` bundle — shared across the sync fabric (every machine derives the identical key) and deliberately separate from the R2 access key so rotating the token never orphans encrypted objects. CRDT identity stays over plaintext (the manifest hash is cleartext; pull decrypts before the G-Set union), so cross-machine merge is unaffected. Pull transparently reads legacy plaintext objects (migration-safe); a push with no key configured still uploads but emits a loud per-cycle warning. A new `R2_ENDPOINT` override points sync at any S3-compatible store (MinIO/other providers), which is also how the flow is verified end-to-end without live R2. Source: `apps/cli/src/lib/session/sync/transcript-crypto.ts`, `apps/cli/src/lib/session/sync/transcript-crypto.test.ts`, `apps/cli/src/lib/session/sync/sync.ts`, `apps/cli/src/lib/session/sync/config.ts`, `apps/cli/src/commands/sessions-sync.ts`, `apps/cli/src/lib/daemon.ts`.
38
+ - **Extend session sync to Droid, Grok, Kimi, and OpenCode (RUSH-1467).** `agents sessions sync` now includes these four agents in its upload/download matrix. `SyncAgentSpec` gains an optional `ext` field so agents with non-`.jsonl` transcript files (e.g., Kimi `state.json`) are walked correctly. Droid `.jsonl` rollouts, Grok `events.jsonl` streams, and Kimi `state.json` metadata files round-trip through the R2 mirror; OpenCode is slotted in `SYNC_AGENTS` but remains a placeholder because its sessions live in a SQLite DB and still require an SQLite-to-JSONL export step. Source: `apps/cli/src/lib/session/sync/agents.ts`, `apps/cli/src/lib/session/sync/agents.test.ts`, `apps/cli/src/commands/sessions-sync.ts`.
39
+ - **`agents repos` is canonical (`repo` alias); push/pull echo the resolved target; push no longer no-ops when clean-but-ahead; pull rebases on diverge (RUSH-1454).** Help now prints `Usage: agents repos …`. Push/pull report `user (~/.agents → origin/main): …` instead of the bare alias. `commitAndPush` still `git push`es when the tree is clean but local is ahead of origin (previously returned success without pushing). `pullRepo` uses `git pull --rebase` so divergent branches reconcile instead of failing with a raw git error. Source: `apps/cli/src/commands/repo.ts`, `apps/cli/src/lib/git.ts`, `apps/cli/src/lib/startup/command-registry.ts`.
40
+ - **`agents run --host <name> --copy-creds` provisions runtime credentials on a persistent host (RUSH-1608).**
41
+ Reuses the `--lease` credential path (`resolveClaudeCredentialsBlob` + the
42
+ `~/.claude/.credentials.json` bootstrap) but makes copying tokens to a
43
+ persistent host strictly opt-in per run. The user picks runtimes, sees a
44
+ consent prompt naming accounts and the Claude OAuth token, and the files are
45
+ shredded after the run. Source: `apps/cli/src/commands/exec.ts`,
46
+ `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/lib/hosts/credentials.ts`,
47
+ `apps/cli/src/lib/hosts/credentials.test.ts`.
48
+
49
+ ## 1.20.62
50
+
51
+ - **Browser downloads land in a known per-profile dir; profile data is consolidated.** A browser profile is now one self-contained tree under `~/.agents/.cache/browser/<profile>/`: `chrome-data/`, `downloads/`, and `sessions/<task>/` (screenshots, PDFs, recordings). Previously downloads had no configured destination — the CLI only set the download dir when the agent explicitly ran `browser download --path`, so absent that call a download fell to Chromium's own default (for an attached user browser like comet, wherever *that* browser was last configured), which is how downloads escaped into random locations. The service now sets the profile's `downloads/` dir browser-global at connect time (both fresh launch and every attach path), so downloads always land somewhere agents-cli controls; `browser download --path` becomes an optional override (omit it to use the profile default) and reports the resolved path. Screenshots/PDFs/recordings moved from the old GLOBAL `browser/sessions/<task>/` root to the per-profile `browser/<profile>/sessions/<task>/`, with a one-shot migration that folds existing captures into the owning profile (attributed via each profile's `tasks.json`; unattributable captures go to a `_legacy` bucket). New `agents browser sessions [--profile <name>] [--open latest|<file>] [--json]` lists a profile's captures + downloads, aliased as `agents sessions --browser`. Source: `apps/cli/src/lib/browser/{profiles,service,ipc,sessions-list}.ts`, `apps/cli/src/lib/migrate.ts`, `apps/cli/src/commands/{browser,sessions}.ts`.
52
+ - **Wire Goose commands support (RUSH-1572).** `agents` now syncs slash commands to Goose as recipe YAML files under `~/.config/goose/commands/<name>.yaml`, each registered in `~/.config/goose/config.yaml` under a `slash_commands: [{ command, recipe_path }]` array (Goose has no native slash-command file format — a slash command IS a recipe). The command recipes live in a dir distinct from the workflow recipes dir (`~/.config/goose/recipes/`) so the workflow detector never treats a command recipe as a workflow. Registration is a read-modify-write that preserves every other `config.yaml` key (`mcp_servers`, `extensions`, …) and other `slash_commands` entries, and removal soft-deletes the recipe + unregisters the entry. Flip Goose's `commands` capability and add `goose` branches to install/list/match/remove, the staleness commands writer, and doctor-diff, backed by a new `goose-commands.ts` module + a `markdownToGooseRecipe` converter. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/goose-commands.ts`, `apps/cli/src/lib/commands.ts`, `apps/cli/src/lib/convert.ts`, `apps/cli/src/lib/staleness/writers/commands.ts`, `apps/cli/src/lib/doctor-diff.ts`.
53
+ - **Fix: Hermes plugin sync no longer disables a plugin the user explicitly enabled.** The plugin install path (RUSH-1688) unconditionally forced `plugins.enabled` to the exec-surface trust verdict on every sync. An ordinary un-flagged background re-sync computes `enable=false` for a plugin with hooks/tools, so it stripped that plugin from the `~/.hermes/config.yaml` allowlist — clobbering a plugin the user deliberately enabled with `--allow-exec-surfaces`. The install path now enables only when trusted and never down-toggles (matching the marketplace flow's add-if-trusted semantics); removal still unregisters explicitly. Source: `apps/cli/src/lib/plugins.ts`.
54
+ - **Wire Goose subagents support (RUSH-1573).** `agents` now syncs subagents to Goose as recipe YAML files under `~/.config/goose/agents/<name>.yaml` — Goose has no dedicated subagent format, so a named subagent IS a recipe (goose auto-discovers `~/.config/goose/agents/` and delegates to them by name in autonomous mode). `transformSubagentForGoose` emits the same recipe schema agents-cli already uses for Goose workflow recipes (`version`/`title`/`description`/`instructions`/`prompt`, plus optional `settings.goose_model`). Flip Goose's `subagents` capability and wire the install/remove/list/orphan/version-remove branches plus the staleness subagents writer and detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
55
+
56
+ - **Wire ForgeCode commands and subagents support (RUSH-1689, RUSH-1690).** `agents` now syncs slash commands to ForgeCode as Markdown files under `~/.forge/commands/<name>.md` (previously ForgeCode had `commands: false` and received commands only as skills), and named subagents as Markdown-with-frontmatter definitions under `~/.forge/agents/<name>.md` (same `color`-less shape as Droid/Copilot/Cursor, so `transformSubagentForForge` aliases `transformSubagentForDroid`). Flip ForgeCode's `commands`/`subagents` capabilities, set `commandsDir`, add the subagent transform plus install/remove/list/orphan/version-remove branches, and register the subagents writer + detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
57
+ - **Wire allowlist (permissions) support for OpenClaw (RUSH-1570).** OpenClaw gates at TOOL granularity only, so permission sync maps just **blanket** (whole-tool) rules into `~/.openclaw/openclaw.json` `tools.alsoAllow` (allow) / `tools.deny` (deny): `bash → exec`, `read → read`, `write`/`edit → write`, `webfetch → web_fetch`, `websearch → web_search`. Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`, `WebFetch(domain:x)`) have no tool-level equivalent and are skipped — coarse-mapping a specific deny to a whole tool would wrongly gate every use of that tool. The absolute `tools.allow` list is never touched, and all other keys (`mcp`, `exec`, `agents`, …) are preserved on read-modify-write. Flip OpenClaw's `allowlist: true`, add `convertToOpenClawFormat` + the `openclaw` branch in `applyPermissionsToVersion`, register the config path and staleness detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/resources/permissions.ts`, `apps/cli/src/lib/staleness/detectors/permissions.ts`.
58
+ - **Wire subagents support for Cursor CLI (RUSH-1388).** cursor-agent loads custom subagents as Markdown with YAML frontmatter under `~/.cursor/agents/*.md` (project-scoped `.cursor/agents/` also supported natively), same shape as Claude/Droid/Copilot minus the `color` field, gated at `>= 2026.1.22` (cursor-agent's CalVer build tag for Cursor 2.4). Flip Cursor's `subagents`, add `transformSubagentForCursor` (alias of `transformSubagentForDroid`), and wire the install/remove, list, orphan-detection, writer, and detector paths. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
59
+ - **Wire hooks support for Hermes (RUSH-1687).** `agents` now registers central hooks into Hermes Agent's `~/.hermes/config.yaml` under a `hooks:` block (YAML, ≥ 0.11.0). The registrar read-modify-writes that shared config so sibling keys like `mcp_servers` survive, maps canonical events to Hermes' snake_case lifecycle names (`SessionStart→on_session_start`, `SessionEnd→on_session_end`, `PreToolUse→pre_tool_call`, `PostToolUse→post_tool_call`, `SubagentStop→subagent_stop`, `UserPromptSubmit→pre_llm_call`, `Stop→on_session_finalize`), and clamps each hook's timeout to 300s (default 60s). Managed entries are re-synced idempotently while user-authored hooks are preserved. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/hooks.ts`, `apps/cli/src/lib/staleness/writers/hooks.ts`.
60
+
5
61
  ## 1.20.61
6
62
 
7
63
  - **Detect OpenCode sign-in state so `agents view` stops mislabeling a logged-in install as "not signed in."** `getAccountInfo` had no `opencode` case, so it fell through to `signedIn: false` and every row printed "(not signed in — run opencode to log in)" even with a live login. It now reads OpenCode's `auth.json` (`$XDG_DATA_HOME/opencode/auth.json`, defaulting to `~/.local/share/opencode/auth.json` on every platform — `xdg-basedir` does not special-case macOS), validates each provider entry against its `oauth`/`api`/`wellknown` credential shape, and reports the account as signed in with the non-secret provider ids surfaced as the account label (e.g. `id:muse-spark`). Credential secrets (`access`/`refresh`/`key`/`token`) are only inspected for presence — never read into any display or JSON output. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/agents.test.ts`.
@@ -16,6 +72,7 @@
16
72
  - **Wire Gemini permissions/allowlist support (RUSH-1567).** Gemini permission groups now sync Bash allow/deny rules into `.gemini/settings.json` as `tools.core` / `tools.exclude` entries with per-command `ShellTool(...)` patterns; non-Bash canonical permissions remain unsupported by Gemini's native tool grammar and are skipped. Flip `allowlist: true`, register the permission writer/detector through the capability table, and replace the dormant legacy `tools.allowed` serializer. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/resources/permissions.ts`, `apps/cli/src/lib/staleness/detectors/permissions.ts`.
17
73
  - **Fix: `agents run <agent>@<version> --host <host>` now forwards the version pin and most run flags to the remote host.** Previously the `--host` branch stripped `@version` and ignored `--strategy`, `--effort`, `--add-dir`, `--json`, `--verbose`, `--timeout`, `--yes`, and `--acp`, so the remote host applied its own defaults. The local CLI now parses `agent@version` verbatim, normalizes `--strategy`/`--balanced`, makes `--add-dir` paths remote-portable, and forwards all of these flags to the remote `agents run` invocation. `--add-dir` portability uses the same `~`/`$HOME` re-rooting that `--cwd` already uses, so a Linux remote resolves home paths against its own `/home/<user>`. Source: `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/hosts/dispatch.test.ts`.
18
74
  - **Fix: routine edits no longer rewrite the whole YAML file, so `~/.agents` stays clean and `agents repo pull` can sync routines across the fleet.** `writeJob` previously re-emitted the entire document via `yaml.stringify` on every mutation (pause/resume, `routines devices --set`, add), restyling untouched scalars — unquoting `schedule`, re-wrapping the folded `prompt` block — which left the git-backed user repo perpetually dirty. That made cross-device `agents repo pull` refuse ("uncommitted changes"), so a `devices:` pin set on one machine never reached the others and `Devices: all` routines kept firing on every box. A new `serializeJob` edits only the changed keys via the YAML Document API, preserving byte-for-byte formatting of untouched nodes; new/unparseable/non-mapping files fall back to canonical stringify. Source: `apps/cli/src/lib/routines.ts`, `apps/cli/src/lib/__tests__/routines.serialize.test.ts`.
75
+ - **`agents sessions --active --json` now carries live plan progress (RUSH-1380).** The state engine parses the latest `TodoWrite` off the transcript tail into `ActiveSession.todos` (`{ items: [{ content, status, activeForm? }], done, total, activeForm }`), and the live preview verb reads `Plan N/M: <current step>` instead of a bare "TodoWrite". This lets consumers (the Factory Floor) show an N/M pill + checklist for every session — including remote / device-dispatched agents that have no local tool-call stream. Source: `apps/cli/src/lib/session/state.ts` (`extractTodoProgress`, `inferActivity`), `apps/cli/src/lib/session/active.ts` (`ActiveSession.todos`, `applyState`), `apps/cli/src/lib/session/parse.ts` (`summarizeToolUse`).
19
76
 
20
77
  ## 1.20.59
21
78
 
package/README.md CHANGED
@@ -347,6 +347,9 @@ agents hosts check gpu-box # reachable? which agents-cli version?
347
347
  # Run there instead of locally
348
348
  agents run claude --host gpu-box "profile this build" # headless: follows live by default
349
349
  agents run claude --host gpu-box # no prompt → interactive TTY over SSH (tmux-backed)
350
+ agents run claude --host gpu-box --copy-creds "fix auth" # copy local runtime creds + Claude token, shred after
351
+ agents hosts ps # list dispatched runs + terminal status
352
+ agents hosts stop <id> # terminate a hung/detached run (alias: kill)
350
353
  agents logs --host gpu-box # pick a dispatched run — concise summary by default
351
354
  agents logs <id> --full # the full raw transcript / stdout (token-heavy)
352
355
  agents logs <id> -f # re-attach to a running one and follow
@@ -682,6 +685,13 @@ agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
682
685
 
683
686
  agents routines devices nightly-drain --set yosemite-s0,mac-mini # update allowlist
684
687
  agents routines list --host yosemite-s0 # query another device
688
+
689
+ # Signed webhook trigger: Linear issue labeled "agent" fires a routine
690
+ agents routines add agent-labeled-issue --on linear:Issue --action update \
691
+ --team-key RUSH --label agent --agent claude \
692
+ --prompt "Work the Linear issue that was just labeled agent"
693
+ agents webhook serve --secrets-bundle webhooks --port 8787 # /hooks/linear, /hooks/github
694
+ agents funnel up yosemite-s0 --local-port 8787 --port 443 # public HTTPS ingress
685
695
  ```
686
696
 
687
697
  Jobs run sandboxed -- agents only see directories and tools you explicitly allow.
package/dist/bin/agents CHANGED
Binary file
@@ -8,6 +8,7 @@ import { readAndResolveBundleEnv, bundleExists, readBundle, describeBundle } fro
8
8
  import { findBrowserPath, getPortOccupant, isLauncherScript } from '../lib/browser/chrome.js';
9
9
  import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, } from '../lib/browser/runtime-state.js';
10
10
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
11
+ import { runBrowserSessions } from '../lib/browser/sessions-list.js';
11
12
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
12
13
  import { parseTargetFilter } from '../lib/browser/service.js';
13
14
  import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest, } from '../lib/browser/ipc.js';
@@ -1850,10 +1851,10 @@ function registerTaskCommands(browser) {
1850
1851
  // ─── Downloads ───────────────────────────────────────────────────────────────
1851
1852
  browser
1852
1853
  .command('download')
1853
- .description('Set download directory for a task')
1854
+ .description("Set the download directory for a task (defaults to the profile's downloads dir)")
1854
1855
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1855
1856
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1856
- .requiredOption('-p, --path <dir>', 'Download directory path')
1857
+ .option('-p, --path <dir>', "Download directory path (default: the profile's downloads dir)")
1857
1858
  .action(async (opts) => {
1858
1859
  const task = resolveTaskName(opts);
1859
1860
  const response = await sendIPCRequest({
@@ -1866,7 +1867,16 @@ function registerTaskCommands(browser) {
1866
1867
  console.error(response.error);
1867
1868
  process.exit(1);
1868
1869
  }
1869
- console.log(`Download path set to ${opts.path}`);
1870
+ console.log(response.downloadPath ? `Download path set to ${response.downloadPath}` : 'Download path set');
1871
+ });
1872
+ browser
1873
+ .command('sessions')
1874
+ .description('List a profile\'s captured screenshots, PDFs, recordings, and downloads')
1875
+ .option('--profile <name>', 'Only this profile (default: all profiles with captures)')
1876
+ .option('--open [selector]', "Open a capture in the OS default app: 'latest' or a filename")
1877
+ .option('--json', 'Emit machine-readable JSON')
1878
+ .action((opts) => {
1879
+ runBrowserSessions({ profile: opts.profile, open: opts.open, json: opts.json });
1870
1880
  });
1871
1881
  // ─── Recording ─────────────────────────────────────────────────────────────
1872
1882
  const record = browser.command('record').description('Record a video of the page');
@@ -242,6 +242,7 @@ export function registerRunCommand(program) {
242
242
  .option('--remote-cwd <dir>', 'Explicit host working directory for --host runs (overrides --cwd; usually --cwd suffices).')
243
243
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
244
244
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
245
+ .option('--copy-creds', 'With --host, copy the picked runtime credentials (and Claude OAuth token) to the host, then shred them after the run. Opt-in per run.')
245
246
  .option('--lease [backend]', 'Invent a disposable cloud box for this run and tear it down after (via crabbox). Optional backend selects the cloud (hetzner/aws/do). Unlike --host, no machine is registered.')
246
247
  .option('--keep-box', 'With --lease, keep the box after the run instead of stopping it.');
247
248
  // `--on` and `--computer` are hidden aliases of `--host` — same behavior.
@@ -536,6 +537,44 @@ export function registerRunCommand(program) {
536
537
  // which can't run over a detached remote dispatch — only forward a
537
538
  // concrete id.
538
539
  const resumeId = typeof options.resume === 'string' ? options.resume : undefined;
540
+ // --copy-creds: provision runtime credentials (and the Claude OAuth token)
541
+ // on the remote host before the run, then shred them after. Unlike
542
+ // --lease, a host is persistent, so this is strictly opt-in per run.
543
+ let hostCopyCreds;
544
+ if (options.copyCreds) {
545
+ const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
546
+ const { confirm } = await import('@inquirer/prompts');
547
+ const detected = await detectSignedInRuntimes();
548
+ const runtimes = await pickRuntimes(detected);
549
+ if (runtimes.length === 0) {
550
+ console.error(chalk.yellow('No runtimes selected. Sign into one locally then retry, or omit --copy-creds.'));
551
+ process.exit(1);
552
+ }
553
+ const names = runtimes
554
+ .map((id) => {
555
+ const d = detected.find((x) => x.id === id);
556
+ return `${d?.label ?? id}${d?.email ? ` (${d.email})` : ''}`;
557
+ })
558
+ .join(', ');
559
+ const whatShips = runtimes.includes('claude') ? 'credentials + Claude OAuth token' : 'credentials';
560
+ const ok = await confirm({
561
+ message: `Copy ${whatShips} for ${names} to host "${host.name}", run there, then shred them after?`,
562
+ default: false,
563
+ });
564
+ if (!ok) {
565
+ console.error(chalk.yellow('Aborted — no credentials pushed, no run dispatched.'));
566
+ process.exit(1);
567
+ }
568
+ let claudeCredentialsJson = null;
569
+ if (runtimes.includes('claude')) {
570
+ const claudeEmail = detected.find((d) => d.id === 'claude')?.email ?? null;
571
+ claudeCredentialsJson = await resolveClaudeCredentialsBlob({ preferEmail: claudeEmail });
572
+ if (!claudeCredentialsJson) {
573
+ console.error(chalk.yellow('Warning: could not read the local Claude OAuth token — the host may boot Claude "Not logged in".'));
574
+ }
575
+ }
576
+ hostCopyCreds = { runtimes, detected, claudeCredentialsJson };
577
+ }
539
578
  // Decide whether this host run is interactive. No prompt always means
540
579
  // interactive (matching local resolveInteractive); --interactive forces
541
580
  // interactive even when a prompt is provided; --headless forces headless
@@ -587,6 +626,7 @@ export function registerRunCommand(program) {
587
626
  passthroughArgs,
588
627
  raw: options.raw || options.tmux === false || options.disableTmux === true,
589
628
  forceInteractive: options.interactive,
629
+ copyCreds: hostCopyCreds,
590
630
  });
591
631
  process.exit(exitCode);
592
632
  }
@@ -616,6 +656,7 @@ export function registerRunCommand(program) {
616
656
  name: options.name,
617
657
  resume: resumeId,
618
658
  follow: options.follow !== false,
659
+ copyCreds: hostCopyCreds,
619
660
  });
620
661
  // Register the dispatched run in the LOCAL session index so it shows
621
662
  // up in `agents sessions` and resolves by id/name, even though its
@@ -1081,8 +1122,17 @@ export function registerRunCommand(program) {
1081
1122
  if (nativeResume(agent)) {
1082
1123
  resumeNative = true;
1083
1124
  resumeSessionId = session.id;
1125
+ // Native `--resume` (claude/codex) resolves the transcript relative to the
1126
+ // working directory (projects/<cwd-hash>/). The session may have been started
1127
+ // in a different directory than we're standing in now — most importantly when a
1128
+ // routine daemon fires `agents run --resume` from its own cwd. Spawn from the
1129
+ // session's ORIGIN cwd so the resume actually finds it; otherwise the agent
1130
+ // exits "No conversation found with session ID". Honor an explicit --cwd only if
1131
+ // the caller passed one (they're overriding on purpose).
1132
+ if (!options.cwd && session.cwd)
1133
+ options.cwd = session.cwd;
1084
1134
  if (!options.quiet)
1085
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}\n`));
1135
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}${!options.cwd || options.cwd === session.cwd ? ` in ${session.cwd ?? cwd}` : ''}\n`));
1086
1136
  }
1087
1137
  else {
1088
1138
  // Tier-2: launch fresh with a /continue <id> first message; the agent
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `agents funnel` — thin Tailscale Funnel wrapper for webhook ingress nodes.
3
+ */
4
+ import type { Command } from 'commander';
5
+ export declare function registerFunnelCommand(program: Command): void;
@@ -0,0 +1,62 @@
1
+ import chalk from 'chalk';
2
+ import { buildFunnelStatusCommand, buildFunnelUpCommand, parseFunnelPort } from '../lib/funnel.js';
3
+ import { resolveHost } from '../lib/hosts/registry.js';
4
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
5
+ import { sshTargetFor } from '../lib/hosts/types.js';
6
+ import { sshExec } from '../lib/ssh-exec.js';
7
+ async function resolveIngressHost(name) {
8
+ const host = await resolveHost(name);
9
+ if (!host)
10
+ throw new Error(`Unknown host "${name}". Enroll it with agents hosts add, or sync devices first.`);
11
+ const os = host.os ?? resolveRemoteOsSync(host.name);
12
+ if (os === 'windows') {
13
+ throw new Error('Tailscale Funnel requires a host with the Tailscale CLI; Windows cannot host this ingress path.');
14
+ }
15
+ return host;
16
+ }
17
+ async function runOnHost(hostName, command) {
18
+ const host = await resolveIngressHost(hostName);
19
+ const target = sshTargetFor(host);
20
+ const result = sshExec(target, command, { timeoutMs: 30_000, multiplex: true });
21
+ if (result.code !== 0) {
22
+ throw new Error((result.stderr || result.stdout).trim() || `ssh exited ${result.code ?? 'without a code'}`);
23
+ }
24
+ const out = result.stdout.trim();
25
+ if (out)
26
+ console.log(out);
27
+ }
28
+ export function registerFunnelCommand(program) {
29
+ const funnel = program
30
+ .command('funnel')
31
+ .description('Manage Tailscale Funnel exposure for a fleet webhook receiver.');
32
+ funnel
33
+ .command('status <host>')
34
+ .description('Show Tailscale Funnel status on a fleet host.')
35
+ .action(async (host) => {
36
+ try {
37
+ await runOnHost(host, buildFunnelStatusCommand());
38
+ }
39
+ catch (err) {
40
+ console.error(chalk.red(err.message));
41
+ process.exit(1);
42
+ }
43
+ });
44
+ funnel
45
+ .command('up <host>')
46
+ .description('Expose a localhost webhook receiver through Tailscale Funnel.')
47
+ .requiredOption('--local-port <n>', 'Local receiver port on the remote host')
48
+ .option('--port <n>', 'Public Funnel port: 443, 8443, or 10000', '443')
49
+ .action(async (host, opts) => {
50
+ try {
51
+ const publicPort = parseFunnelPort(opts.port ?? '443');
52
+ const localPort = Number.parseInt(opts.localPort, 10);
53
+ const command = buildFunnelUpCommand(publicPort, localPort);
54
+ await runOnHost(host, command);
55
+ console.log(chalk.green(`Funnel enabled on ${host}: public :${publicPort} → localhost:${localPort}`));
56
+ }
57
+ catch (err) {
58
+ console.error(chalk.red(err.message));
59
+ process.exit(1);
60
+ }
61
+ });
62
+ }
@@ -18,6 +18,7 @@ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
18
  import { listTasks, loadTask, findTaskByName, findTaskBySessionId } from '../lib/hosts/tasks.js';
19
19
  import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
20
20
  import { showHostTaskLog } from '../lib/hosts/logs.js';
21
+ import { stopDispatchedTask } from '../lib/hosts/dispatch.js';
21
22
  /** Parse `user@host` or `host` into its pieces. */
22
23
  function parseTarget(target) {
23
24
  const at = target.indexOf('@');
@@ -201,6 +202,42 @@ async function doLogs(ref, follow, full) {
201
202
  if (res.exitCode !== undefined)
202
203
  process.exitCode = res.exitCode;
203
204
  }
205
+ /** Resolve a host-task ref (id, --name handle, or session id) or null. */
206
+ function resolveTaskRef(ref) {
207
+ return loadTask(ref) ?? findTaskByName(ref) ?? findTaskBySessionId(ref);
208
+ }
209
+ async function doStop(ref) {
210
+ // Heal first so we don't try to kill a process that already exited.
211
+ const current = resolveTaskRef(ref);
212
+ if (!current) {
213
+ console.log(chalk.red(`Unknown task "${ref}".`));
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+ const task = reconcileRunningTasks([current])[0] ?? current;
218
+ if (task.status !== 'running') {
219
+ console.log(chalk.gray(`Task ${task.id} is already ${task.status}` + (task.exitCode !== undefined ? ` (exit ${task.exitCode})` : '') + '.'));
220
+ return;
221
+ }
222
+ try {
223
+ const stopped = stopDispatchedTask(task);
224
+ const statusColor = stopped.status === 'completed' ? chalk.green : chalk.yellow;
225
+ const exitNote = stopped.exitCode === 143
226
+ ? 'exit 143 / SIGTERM'
227
+ : stopped.exitCode !== undefined
228
+ ? `exit ${stopped.exitCode}`
229
+ : stopped.status;
230
+ console.log(chalk.green(`Stopped ${stopped.id}`) +
231
+ chalk.gray(` on ${stopped.host}`) +
232
+ ' ' + statusColor(stopped.status) +
233
+ chalk.gray(` (${exitNote})`));
234
+ console.log(chalk.gray(`Logs: agents hosts logs ${stopped.id}`));
235
+ }
236
+ catch (err) {
237
+ console.error(chalk.red(err?.message ?? err));
238
+ process.exitCode = 1;
239
+ }
240
+ }
204
241
  /** Register the `agents hosts` command tree. */
205
242
  export function registerHostsCommand(program) {
206
243
  const hosts = program
@@ -239,4 +276,9 @@ export function registerHostsCommand(program) {
239
276
  .option('-f, --follow', 'Follow live output')
240
277
  .option('-m, --full', 'Show the full raw combined-stdout log instead of the concise summary')
241
278
  .action((id, opts) => doLogs(id, !!opts.follow, !!opts.full));
279
+ hosts
280
+ .command('stop <id>')
281
+ .alias('kill')
282
+ .description('Terminate a running host task from this machine (SIGTERM process group; marks failed/143).')
283
+ .action((id) => doStop(id));
242
284
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Extra DotAgent repo management.
3
3
  *
4
- * Registers `agents repo add|init|list|remove|enable|disable` which manage
5
- * additional DotAgent repos alongside the primary ~/.agents/.system/ repo so
6
- * private, work, or team skills can ship separately from public ones.
4
+ * Registers `agents repos add|init|list|remove|enable|disable` (`repo` alias)
5
+ * which manage additional DotAgent repos alongside the primary ~/.agents/.system/
6
+ * repo so private, work, or team skills can ship separately from public ones.
7
7
  *
8
8
  * Extras are user-level config: managed clones live at ~/.agents-<alias>/ as
9
9
  * peer dirs to ~/.agents/, and user-owned repos may live anywhere. All extras
@@ -73,6 +73,6 @@ export declare function formatResourceDelta(entries: {
73
73
  export declare function repoSlug(url: string): string;
74
74
  /** Pack colored phrases into `, `-joined lines no wider than `width`. */
75
75
  export declare function wrapPhrases(parts: string[], width: number): string[];
76
- /** Register the `agents repo` command tree. */
76
+ /** Register the `agents repos` command tree (`repo` is a convenience alias). */
77
77
  export declare function registerRepoCommands(program: Command): void;
78
78
  export {};
@@ -9,6 +9,7 @@ import { confirm, input } from '@inquirer/prompts';
9
9
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
10
10
  import { setHelpSections } from '../lib/help.js';
11
11
  import { itemPicker } from '../lib/picker.js';
12
+ import { addHostOption } from '../lib/hosts/option.js';
12
13
  import { inspectRepo, resolveRepoTarget, } from './inspect.js';
13
14
  const HOME = os.homedir();
14
15
  /**
@@ -27,7 +28,7 @@ function resolveRepoPath(target) {
27
28
  return path.join(HOME, `.agents-${trimmed}`);
28
29
  }
29
30
  import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
30
- import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo } from '../lib/git.js';
31
+ import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, displayHomePath, } from '../lib/git.js';
31
32
  import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
32
33
  import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
33
34
  import { refresh } from '../lib/refresh.js';
@@ -465,34 +466,44 @@ async function listRepos(alias, opts = {}) {
465
466
  }
466
467
  console.log('');
467
468
  }
468
- /** Register the `agents repo` command tree. */
469
+ /**
470
+ * Label for push/pull spinners and results: alias + resolved dir + tracking ref.
471
+ * e.g. `user (~/.agents → origin/main)`.
472
+ */
473
+ function formatRepoTarget(alias, dir, branch) {
474
+ const ref = branch ? `origin/${branch}` : 'origin';
475
+ return `${alias} (${displayHomePath(dir)} → ${ref})`;
476
+ }
477
+ /** Register the `agents repos` command tree (`repo` is a convenience alias). */
469
478
  export function registerRepoCommands(program) {
470
- const repoCmd = program
471
- .command('repo')
472
- .alias('repos')
473
- .description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).');
479
+ // addHostOption on the group so --help documents --host/--device; remote
480
+ // routing is handled pre-parse by maybeRunOnHost (passthrough.ts).
481
+ const repoCmd = addHostOption(program
482
+ .command('repos')
483
+ .alias('repo')
484
+ .description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).'));
474
485
  setHelpSections(repoCmd, {
475
486
  examples: `
476
487
  # Scaffold an editable repo (default: ~/.agents/)
477
- agents repo init
488
+ agents repos init
478
489
 
479
490
  # Scaffold a named repo at ~/.agents-work/
480
- agents repo init work
491
+ agents repos init work
481
492
 
482
493
  # Register an existing repo (clones to ~/.agents-<alias>/)
483
- agents repo add gh:yourname/.agents-work
494
+ agents repos add gh:yourname/.agents-work
484
495
 
485
496
  # Register with a custom alias
486
- agents repo add git@github.com:acme/team-skills.git --as acme
497
+ agents repos add git@github.com:acme/team-skills.git --as acme
487
498
 
488
499
  # See what's registered
489
- agents repo list
500
+ agents repos list
490
501
 
491
502
  # View one repo's contents (git state + resource counts); omit the name for a picker
492
503
  agents repos view system
493
504
 
494
505
  # Temporarily disable without deleting
495
- agents repo disable acme
506
+ agents repos disable acme
496
507
  `,
497
508
  notes: `
498
509
  Managed extras live at ~/.agents-<alias>/ as peer dirs to ~/.agents/. User-owned
@@ -850,20 +861,20 @@ export function registerRepoCommands(program) {
850
861
  }
851
862
  continue;
852
863
  }
853
- const spinner = ora(`Pulling ${t.alias}...`).start();
864
+ const spinner = ora(`Pulling ${formatRepoTarget(t.alias, t.dir)}...`).start();
854
865
  const result = await pullRepo(t.dir);
855
866
  if (result.success) {
856
- spinner.succeed(`${t.alias} -> ${result.commit}`);
867
+ spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.commit}`);
857
868
  }
858
869
  else {
859
- spinner.fail(`${t.alias}: ${result.error}`);
870
+ spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
860
871
  }
861
872
  }
862
873
  });
863
874
  repoCmd
864
875
  .command('push [alias]')
865
876
  .description('Commit and push the user repo or a user-owned extra. Refuses to push the system repo.')
866
- .option('-m, --message <msg>', 'Commit message', 'Update via agents repo push')
877
+ .option('-m, --message <msg>', 'Commit message', 'Update via agents repos push')
867
878
  .action(async (alias, options) => {
868
879
  const targets = collectRepoTargets(alias);
869
880
  if (!targets) {
@@ -893,13 +904,13 @@ export function registerRepoCommands(program) {
893
904
  return;
894
905
  }
895
906
  for (const t of pushable) {
896
- const spinner = ora(`Pushing ${t.alias}...`).start();
907
+ const spinner = ora(`Pushing ${formatRepoTarget(t.alias, t.dir)}...`).start();
897
908
  const result = await commitAndPush(t.dir, options.message);
898
909
  if (result.success) {
899
- spinner.succeed(`${t.alias} pushed`);
910
+ spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.detail ?? 'pushed'}`);
900
911
  }
901
912
  else {
902
- spinner.fail(`${t.alias}: ${result.error}`);
913
+ spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
903
914
  }
904
915
  }
905
916
  });