@phnx-labs/agents-cli 1.20.50 → 1.20.51

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 (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/cloud.js +1 -25
  4. package/dist/commands/computer.d.ts +1 -0
  5. package/dist/commands/computer.js +129 -8
  6. package/dist/commands/exec.js +49 -6
  7. package/dist/commands/factory.js +1 -4
  8. package/dist/commands/inspect.js +1 -11
  9. package/dist/commands/mcp.js +2 -6
  10. package/dist/commands/message.js +1 -4
  11. package/dist/commands/profiles.js +1 -18
  12. package/dist/commands/repo.js +33 -14
  13. package/dist/commands/resource-view.d.ts +1 -0
  14. package/dist/commands/resource-view.js +5 -17
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +1 -28
  17. package/dist/commands/sessions-picker.js +1 -18
  18. package/dist/commands/sessions.js +6 -8
  19. package/dist/commands/teams-picker.js +1 -32
  20. package/dist/commands/teams.js +1 -27
  21. package/dist/commands/tmux.js +1 -3
  22. package/dist/commands/view.js +1 -9
  23. package/dist/commands/worktree.js +1 -4
  24. package/dist/lib/agents.d.ts +0 -4
  25. package/dist/lib/agents.js +20 -33
  26. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  27. package/dist/lib/auto-dispatch-linear.js +107 -0
  28. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  29. package/dist/lib/auto-dispatch-provider.js +25 -0
  30. package/dist/lib/auto-dispatch.d.ts +87 -0
  31. package/dist/lib/auto-dispatch.js +142 -0
  32. package/dist/lib/browser/cdp.js +11 -2
  33. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  34. package/dist/lib/browser/drivers/ssh.js +57 -18
  35. package/dist/lib/browser/refs.js +1 -5
  36. package/dist/lib/cli-resources.d.ts +0 -2
  37. package/dist/lib/cli-resources.js +30 -13
  38. package/dist/lib/cloud/rush.d.ts +0 -24
  39. package/dist/lib/cloud/rush.js +0 -31
  40. package/dist/lib/crabbox/cli.js +4 -1
  41. package/dist/lib/crabbox/lease.js +29 -1
  42. package/dist/lib/daemon.js +41 -0
  43. package/dist/lib/exec.js +31 -14
  44. package/dist/lib/format.d.ts +38 -0
  45. package/dist/lib/format.js +108 -0
  46. package/dist/lib/git.d.ts +21 -0
  47. package/dist/lib/git.js +92 -0
  48. package/dist/lib/hooks/cache.d.ts +9 -2
  49. package/dist/lib/hooks/cache.js +220 -8
  50. package/dist/lib/hooks.js +17 -8
  51. package/dist/lib/platform/exec.d.ts +4 -1
  52. package/dist/lib/platform/exec.js +8 -2
  53. package/dist/lib/resources.d.ts +0 -8
  54. package/dist/lib/resources.js +0 -10
  55. package/dist/lib/runner.js +10 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/session/active.d.ts +11 -1
  59. package/dist/lib/session/active.js +3 -0
  60. package/dist/lib/session/db.d.ts +1 -4
  61. package/dist/lib/session/db.js +20 -25
  62. package/dist/lib/session/discover.d.ts +2 -2
  63. package/dist/lib/session/discover.js +61 -48
  64. package/dist/lib/session/parse.js +35 -34
  65. package/dist/lib/session/render.d.ts +7 -3
  66. package/dist/lib/session/render.js +15 -9
  67. package/dist/lib/session/state.d.ts +55 -0
  68. package/dist/lib/session/state.js +87 -10
  69. package/dist/lib/session/types.d.ts +9 -0
  70. package/dist/lib/shims.d.ts +9 -3
  71. package/dist/lib/shims.js +44 -8
  72. package/dist/lib/ssh-tunnel.d.ts +33 -2
  73. package/dist/lib/ssh-tunnel.js +94 -7
  74. package/dist/lib/staleness/types.d.ts +0 -1
  75. package/dist/lib/types.d.ts +14 -1
  76. package/dist/lib/versions.d.ts +0 -26
  77. package/dist/lib/versions.js +2 -145
  78. package/dist/lib/warn-unpushed.d.ts +40 -0
  79. package/dist/lib/warn-unpushed.js +128 -0
  80. package/package.json +3 -1
  81. package/dist/lib/resources/index.d.ts +0 -53
  82. package/dist/lib/resources/index.js +0 -76
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.51
6
+
7
+ - **Fix: `agents run --lease` bootstraps a fresh crabbox image and no longer leaks the box after the run.** Three failures compounded on a stock Hetzner lease (Ubuntu 24.04, no node preinstalled): (1) the bootstrap's `npm install -g @phnx-labs/agents-cli` ran with no node/npm on the box and swallowed the failure with `|| true`, so every run died deep in the script with `agents: command not found` (exit 127) and no hint why; (2) even with the CLI installed, a fresh install refuses `agents run` with "agents-cli is not set up" until `agents setup` has run; (3) teardown called `crabbox stop --id <slug>`, but crabbox's `stop` takes a positional target (unlike `status`/`run`/`ssh`) and died with `flag provided but not defined: -id` — silently, because `crabboxStop` is best-effort — so every one-shot lease box was **kept, billed, and left carrying the run's working data** until someone noticed (`Box … kept` instead of destroyed). The bootstrap now: exports `~/.local/bin` onto PATH, installs node user-level from the official `latest-v22.x` tarball when missing (arch-aware, satisfies `engines.node >=22.5.0`, no sudo needed), points the npm prefix at `~/.local`, fails loud with exit 96 and a diagnostic when the CLI still isn't runnable, and runs `agents setup` behind the same `[ ! -d ~/.agents/.system ]` first-run guard the hosts bootstrap uses; `crabboxStop` passes the slug positionally. Verified live on a fresh Hetzner cpx62 by the run's own progression across builds: the pre-fix lease exited 127 (`agents: command not found`) with `Box … kept`; after the node/npm fix it reached `agents-cli is not set up`; after the setup fix it reached the agent's login check (`Not logged in`); and every post-fix run ends with `Box <slug> destroyed.` instead of leaking. Source: `apps/cli/src/lib/crabbox/lease.ts` (`ENSURE_AGENTS_CLI`, `buildBootstrapScript`), `apps/cli/src/lib/crabbox/cli.ts` (`crabboxStop`). Known follow-up: leasing a **Claude** runtime from a Mac whose Claude Code credential lives in the login Keychain (the default install, and any agents-cli managed home — service name is hash-suffixed) still lands "Not logged in" on the box, because the picker copies `~/.claude.json` (config/state) but not the OAuth token, and extracting the token from the Keychain needs an interactive ACL approval; tracked separately.
8
+ - **`agents repo pull user <git-url>` now git-backs a plain `~/.agents` instead of silently skipping it — fixing config sync on Windows/fresh machines.** Setup only ever git-clones the *system* repo (`~/.agents/.system/`); the user repo is created as a bare directory (`state.ts ensureAgentsDir`), so `~/.agents` is git-backed only where it was cloned by hand as a dotfiles step. On a box where that never happened (a fresh install, or Windows), `agents repo pull` just printed `user: not a git repo, skipping` and the machine silently fell out of config sync — no `rules/`, no `agents sync` of shared resources. Now, passing your config remote once — `agents repo pull user git@github.com:you/.agents.git` — **adopts** the existing directory in place: it clones your remote and moves the `.git` in without deleting anything, materializes the tracked resources it was missing, and **backs up any locally-modified tracked file** (e.g. a machine-specific `agents.yaml`) to a sibling `~/.agents.pre-adopt-backup/` before overwriting it. Untracked runtime state (`.cache/`, `.history/`, `.system/` — all gitignored) is never touched. Every subsequent `agents repo pull` / `agents sync` is plain (the remote is now `origin`). No new command; the URL is only needed the first time. SSH transport is preserved (a `git@…` URL clones over SSH, not a rewritten https that would hang on a private-repo credential prompt), and git never prompts (`GIT_TERMINAL_PROMPT=0`). Source: `apps/cli/src/lib/git.ts` (`adoptRepo`), `apps/cli/src/commands/repo.ts`.
9
+ - **`agents run` now warns when a headless run leaves committed-but-unpushed work, instead of stranding it silently.** A headless `agents run` in a writable mode (`edit`/`skip`/`auto`) could end with the agent having committed on a branch but never pushed it — the run's exit path did no git work, so those commits sat invisible in a worktree until someone audited the box (exactly how a batch dispatch loop can quietly lose a verified fix). After a non-interactive, writable run the CLI now inspects the cwd for commits on the current branch that haven't reached any remote (`git log HEAD --not --remotes`, correct even when no upstream is set — work already on an `origin/*` ref is not flagged) and prints a loud stderr warning naming the branch, the unpushed commits, and the exact `git push` / `gh pr create` commands. Advisory only: it never pushes, never mutates the repo, and never throws (a 5s git timeout plus full error-swallowing guarantee it can't delay or break the run's exit). The check is wired into every headless exit path — single run, `--loop`, `--acp`, `--resume-checkpoint`, and the crash/catch path — and gated by `shouldWarnUnpushed(mode, interactive)` so it stays silent for interactive runs (the human sees their shell) and read-only `plan` mode. Source: `apps/cli/src/lib/warn-unpushed.ts`, `apps/cli/src/commands/exec.ts`. (#868)
10
+ - **Codex mode flags now match what the mode names promise — only `--mode skip` is yolo.** `--mode edit` used to append `--dangerously-bypass-approvals-and-sandbox` (Codex's `--yolo`) alongside `--sandbox workspace-write`, and the bypass flag wins — so "edit" silently ran Codex with **no sandbox and no approvals**, verified against codex 0.142.5's own session banner (`sandbox: danger-full-access`). And `--mode plan` mapped to `workspace-write` (writable!) because the template predated Codex's `read-only` sandbox. Now: `plan` → `--sandbox read-only`, `edit` → `--sandbox workspace-write -c sandbox_workspace_write.network_access=true` (sandboxed writes, network on so git/gh/installs keep working, no approval bypass), `skip` → `--dangerously-bypass-approvals-and-sandbox` (unchanged — skip IS the gnarly mode, equivalent to `codex --yolo`). Same fix in routine jobs (`runner.ts`) and in headless `codex exec resume`, which used to get the bypass for ANY non-plan resume — it now maps plan/edit through `-c sandbox_mode=…` and reserves the bypass for skip; interactive `codex resume` now carries the mode's sandbox flags instead of none. Verified live per mode against codex 0.142.5 session banners: skip = `approval: never / sandbox: danger-full-access`, edit = `sandbox: workspace-write (network access enabled)`, plan = `sandbox: read-only`. Source: `apps/cli/src/lib/exec.ts` (`AGENT_COMMANDS.codex`, resume block), `apps/cli/src/lib/runner.ts` (`buildJobCommand`).
11
+ - **`--add-dir` is now forwarded to Codex (it was silently dropped).** `agents teams` passes `--add-dir ~/.agents` so Codex teammates can run `agents teams add`, but `buildExecCommand` emitted `--add-dir` for Claude only — the grant never reached Codex, masked until now by edit mode's accidental sandbox bypass. Codex takes `--add-dir` natively (widens the workspace-write sandbox); it is now forwarded for fresh runs and skipped on resume (`codex exec resume` rejects it). Source: `apps/cli/src/lib/exec.ts`.
12
+ - **Fix: the documented `agents run <agent> [prompt] -- <native flags>` passthrough works again.** commander ≥13 rejects excess operands by default, so any post-`--` token (e.g. `agents run codex -- --yolo`) died with `too many arguments` before the run started. The run command now allows excess operands, re-derives the `--` boundary from argv (a post-`--` token can never be mis-parsed as the prompt — `agents run codex -- --yolo` launches the TUI with `--yolo`, it doesn't headless-run the "prompt" `--yolo`), and still errors, with a hint to quote the prompt, on excess operands NOT behind `--`. Verified live: `agents run codex "…" -- --yolo` forwards `--yolo` and codex reports `sandbox: danger-full-access`. Source: `apps/cli/src/commands/exec.ts`.
13
+
14
+ - **Fix: grok launch shims resolve the binary from the versioned home before the global `~/.grok/downloads`, so a pinned grok that installed into the versioned home no longer dies with "grok@<version> not installed."** Grok ships a native binary (not an npm package), and it lands in the versioned home's `.grok/downloads` whenever the installer runs with `GROK_HOME` set — via the shim, a correct `agents add grok`, or a grok self-update from within the shim. Both generated shims (the dispatcher in `generateShimScript` and the `grok@<version>` versioned alias in `generateVersionedAliasScript`) checked only `$HOME/.grok/downloads`, which was often empty, so they fell through to the "not installed" error even though the binary existed in the versioned home. `getBinaryPath` already checked the versioned home first, so `agents view` and the shims disagreed. Both shim blocks now check `$VERSION_DIR/home/.grok/downloads` first and fall back to the global `$HOME/.grok/downloads` for pre-fix installs, then the existing adopted-launcher/PATH last resort. Bumps `SHIM_SCHEMA_VERSION` 25→26 and `VERSIONED_ALIAS_SCHEMA_VERSION` 12→13 so existing on-disk grok shims regenerate. Supersedes the pre-monorepo #830. Source: `apps/cli/src/lib/shims.ts` (`generateShimScript` grok dispatcher block, `generateVersionedAliasScript` `binaryResolution`).
15
+ - **Fix: `browser stop --host <windows>` tree-kills the remote browser — relaunches never wedge on a stale `SingletonLock`.** The kill script used `Stop-Process` on the CDP port owner only; orphaned Chromium child processes survived, kept the profile's `SingletonLock` held, and the next `browser start --host` against the same profile exited immediately as a second instance. The script now uses `taskkill /PID <owner> /T /F` to take down the whole process tree. Source: `apps/cli/src/lib/browser/drivers/ssh.ts` (`buildWindowsKillScript`). (GitHub #561)
16
+ - **Fix: `agents browser start --host <windows>` actually serves CDP now — the remote browser launches in the user's interactive session instead of session 0.** The Windows launch used WMI `Win32_Process.Create` (chosen so the browser outlives the ssh session), but a WMI-created process lands in session 0, where Edge binds the debugging port yet its DevTools server never initializes — every `/json/version` probe hung forever and `DevToolsActivePort` was never written, so `browser start --host` failed with a connection error on every attempt. The launch is now a one-shot scheduled task registered and started by the logged-on user: it survives ssh disconnect the same way, runs in the interactive session where DevTools comes up normally, and is unregistered immediately after start. The launch args also gained the same automation-modal suppressors the local launcher has (`--no-first-run --no-default-browser-check --hide-crash-restore-bubble --disable-session-crashed-bubble`) — without them a relaunch against a previously hard-killed profile triggers session-restore churn that closes the CDP page target mid-command. Verified live against win-mini (Edg/150). Source: `apps/cli/src/lib/browser/drivers/ssh.ts` (`buildWindowsLaunchScript`). (GitHub #561)
17
+ - **Fix: remote CDP no longer dies on large payloads — screenshots of content-rich pages over `browser --host` work.** The CDP client rode the platform (undici) WebSocket, which enforces a non-configurable max decompressed message size; a `Page.captureScreenshot` response for a content-rich page blew past it and the socket closed with 1006 ("Max decompressed message size exceeded") while the command was pending, surfacing as "CDP connection closed". The websocket transport now uses the `ws` client (no permessage-deflate offer by default, explicit 256MB `maxPayload`); the local pipe transport is unchanged. Source: `apps/cli/src/lib/browser/cdp.ts`. (GitHub #561)
18
+ - **`agents sessions <id> --json` now exposes the ExitPlanMode plan markdown as a top-level field, and the shape changed from a bare event array to `{ session, events }`.** The session-state engine already detected plan-review (`awaitingReason: 'plan_review'`) off a trailing `ExitPlanMode` tool call, but the plan markdown itself was dropped on the floor — forcing every consumer that wanted it (the Factory NEEDS-YOU panel via `parsePlanFromClaudeJsonl`, external dashboards) to re-open the raw JSONL and scan for the same tool call. That "extension re-implements the session engine" gap now closes at the source: the state engine surfaces `state.plan` alongside `awaitingReason`, the Claude scanner captures the plan text at scan time and persists it to `sessions.db` (schema v11, additive, rescan-on-migrate), it's exposed as `plan` on `SessionMeta` in every `agents sessions --json` row, and `agents sessions <id> --json` now emits `{ session: SessionMeta, events: SessionEvent[] }` so the plan is one top-level `output.session.plan` read instead of a needle-in-haystack scan. Verified live against a real Claude session with an ExitPlanMode event: `agents sessions 74464df7 --json` prints the plan markdown at `.session.plan`. Source: `apps/cli/src/lib/session/{state,discover,db,render,types}.ts`, `apps/cli/src/lib/session/active.ts`, `apps/cli/src/commands/sessions.ts`. (issue #743 / RUSH-1505)
19
+ - **`agents computer` Windows parity: scoped screenshots, `get-text --max-chars`, `status`/`reload --host`, and honest `--background`/`--require-frontmost` handling (#548).** Four params the CLI already sent were silently ignored by the Windows daemon. (1) **Screenshots are now pid-scoped like macOS** — `screenshot --list` enumerates the target pid's top-level windows (`window_id` is the Win32 HWND, the same id `raise --window-id` takes), the default capture crops to the pid's largest on-screen window, `--window-id` shoots one window, and `--display` captures the display the app is on; previously every capture was the whole virtual desktop. Verified live on win-mini: window capture 2097x984/28KB vs full display 2560x1440/380KB. (2) **`get-text --max-chars` is honored** (default stays 20k, ceiling 200k like macOS) — `--max-chars 100` now returns exactly 100 chars. (3) **`status --host <device>` and `reload --host <device>`** — status reports the recorded tunnel plus a live daemon probe (previously it misreported macOS-local install state for a remote Windows daemon); reload restarts the daemon's scheduled task (the way to pick up a freshly pushed exe) and confirms it answers through the tunnel. (4) **`--require-frontmost` is enforced on Windows** — `SendInput` lands in the *focused* window, so `type-text`/`key` now report `frontmost` (feeding the existing CLI warning) and the flag hard-fails with `not_frontmost` when the target isn't foreground; **`--background` is rejected** with `action_unsupported` instead of silently no-oping (macOS postToPid delivery has no Win32 analogue — element-mode clicks via UIA patterns are the focus-safe path). Source: `native/computer-win/{Screenshot,Automation}.cs`, `apps/cli/src/commands/computer.ts`, `apps/cli/docs/computer.md`.
20
+ - **The `never` prompt-policy is now live — the signed keychain helper was rebuilt, re-notarized, and re-pinned.** `agents secrets create --policy never --i-understand` stores bundle values with no biometry ACL (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, device-local, non-synchronizable) so headless automation can read them with zero Touch ID prompts. The Swift `set-no-acl` path shipped in #682 but the pinned helper binary predated it, so the policy failed against the shipped helper; the helper is rebuilt from current source, notarized (Apple submission `a2373c91-7fc2-4894-a801-b37c111597aa`, status Accepted, stapled, Gatekeeper `Notarized Developer ID`), and `scripts/Agents CLI.app.sha256` re-pinned to the new binary. (GitHub #421)
21
+ - **Consolidated ~30 copy-pasted terminal-formatting helpers into one shared `apps/cli/src/lib/format.ts`, fixing three user-visible drifts at the source.** `die`, `truncate`, `relTime`, `humanDuration`, `visibleWidth`, `padRight`/`padVisible`, `isJsonMode`, `readStdinSync`, and `termLink` had drifted into per-command copies with different behavior; every consumer now imports the single canonical version. Three normalizations are user-visible: (1) the truncation ellipsis is now the single glyph `…` everywhere — `agents cloud` task lists, `agents sessions` overflow, and session prompt/tool summaries previously showed ASCII `...` or a bare `.`; (2) `agents cloud` relative timestamps switch from the long "5 minutes ago" form to the compact "5m ago" form already used by `agents teams`; (3) the `agents teams` picker duration cell gains a space ("2h5m" → "2h 5m") to match the sessions/browser pickers. It also fixes a latent bug: `agents repo`'s divergence-table column alignment used a `visibleWidth` regex missing its `\x1b` escape, so ANSI-colored cells were mis-measured and columns could misalign — the canonical `visibleWidth` strips the full SGR sequence. `lib/events.ts`'s `truncate` (a distinct nullable, exported helper that truncates persisted event payloads) and the domain-specific `statusColor` copies (different status vocabularies with conflicting color assignments) are deliberately left in place. Internal refactor plus the noted string normalizations; no other behavior change. Source: `apps/cli/src/lib/format.ts` and consumers across `apps/cli/src/commands/` and `apps/cli/src/lib/`. (GitHub #753 / RUSH-1515)
22
+ - **`agents computer setup --host` now works from a plain `npm i -g` install — the Windows helper exe downloads on demand from GitHub releases.** The ~157MB `computer-helper-win.exe` never shipped in the npm tarball, so setup died with "Windows helper exe not built. Run: bash scripts/build-win.sh" for anyone without a repo checkout. On `v*` tags the `computer-helper-win.yml` workflow now builds the self-contained exe, smoke-tests it, and uploads it plus a `.sha256` as GitHub release assets; `setup --host` resolves a local build first, then downloads the asset for the exact running CLI version, verifies its sha256 against the published checksum, and caches it under `~/.agents/.cache/computer/win-helper/v<version>/`. A tag with no asset is a hard error naming that tag — never a silent fallback to a different release. (GitHub #547) Source: `apps/cli/src/lib/ssh-tunnel.ts` (`ensureWinHelperExe`, `downloadWinHelperExe`), `.github/workflows/computer-helper-win.yml` (`release-exe`).
23
+ - **`registerMcp` HTTP transport now routes through the capability table instead of an inline agent-id allowlist.** MCP-over-HTTP support and MCP-header support were gated by hardcoded `agentId !== 'claude' && agentId !== 'codex' && agentId !== 'gemini'` / `agentId !== 'claude'` checks in `apps/cli/src/lib/agents.ts`, bypassing the `capabilities.ts`/`supports()` table that is the single source of truth for "which agent supports what." A newly-added agent would silently get the wrong HTTP-MCP behavior with no compile-time signal. Two new capabilities land on the `AgentConfig` matrix — `mcpHttp` (Claude/Codex/Gemini today) and `mcpHeaders` (Claude only) — and both inline allowlists are replaced with `supports(agentId, cap)` calls. Pure refactor: `capableAgents('mcpHttp')` is `['claude','codex','gemini']` and `capableAgents('mcpHeaders')` is `['claude']`, matching the pre-change behavior exactly. Source: `apps/cli/src/lib/{agents,capabilities,types}.ts`, `apps/cli/src/lib/{agents,capabilities}.test.ts`, `apps/cli/src/lib/__tests__/capabilities.test.ts`. (issue #742 / RUSH-1504)
24
+ - **Hook `matches:` predicates are now enforced at fire time — the documented gating was inert.** A hook manifest entry could declare `matches:` predicates (`prompt_contains` / `prompt_matches` / `tool_name` / `tool_args_match` / `cwd_includes` / `project_has` / `git_dirty`) to gate when it fires, and the docs described the gate ("all predicates AND together; an empty block always fires"), but `shouldFire()` (the evaluator in `src/lib/hooks/match.ts`) had **zero runtime callers**: the agent execs the registered command directly and nothing evaluated `matches:`, so any hook with a `matches:` block fired unconditionally. A hook that declares `matches:` (with or without `cache:`) is now registered as a generated wrapper shim that evaluates the predicates against the event JSON on stdin before running the script — a non-matching event exits 0 without running the hook body (logged as `cache:"skip"`), a matching event runs it. Matches-only hooks (no `cache:`) get a gate-only pass-through shim; cached hooks apply the gate before the cache. The shim gate is a faithful port of `shouldFire()` (same AND semantics, same ReDoS guard) and is pinned to it by a 20-case conformance test so the two can't drift. Gating is fail-open: a garbled predicate runs the hook rather than silently disabling a safety hook (e.g. `git-guard`). No installed or bundled hook currently declares `matches:`, so this changes no existing hook's behavior — it activates a documented feature for authors who add one. Verified end-to-end by generating a shim from a `matches: { tool_name: Bash, tool_args_match: "rm -rf" }` manifest and firing it: a `Read` event and a `Bash`+`ls` event were skipped, a `Bash`+`rm -rf` event ran the body. Source: `apps/cli/src/lib/hooks/cache.ts` (`renderShim` gate + pass-through tail), `apps/cli/src/lib/hooks.ts` (`resolveHookCommand`), `apps/cli/docs/hooks.md`. (RUSH-1506)
25
+ - **Browser-over-SSH no longer hangs on an unreachable remote host — it fails fast (~10s).** The raw-`ssh` spawns in the browser SSH driver (`ensureRemoteBrowser`, `runSSHCommand`) passed only `-o BatchMode=yes`, with no `ConnectTimeout`, so a dropped SYN to a down host stalled on the OS default TCP timeout (~127s) instead of erroring. Both call sites now compose the shared hardened baseline `SSH_OPTS` from `ssh-exec.ts` (`BatchMode` + `ConnectTimeout=10` + `ServerAlive` keepalive) rather than re-listing options — the same baseline `sshExec` and the `-L` tunnel already use. The options now also precede the target (matching `sshExec`); on macOS/BSD `getopt` an option placed after the target is swallowed into the remote command instead of applied. Verified against TEST-NET `203.0.113.1` (guaranteed unreachable): `-o BatchMode=yes` alone was still hanging at a 30s cap (en route to ~127s), while the `SSH_OPTS` set failed in `10.04s` with `connect to host 203.0.113.1 port 22: Connection timed out`. Source: `apps/cli/src/lib/browser/drivers/ssh.ts`. (RUSH-1508)
26
+ - **Fix: OpenCode sessions now load on Windows.** Reading OpenCode sessions shelled out to the `sqlite3` CLI at three call sites — `parseOpenCode` (transcript parse) plus the session scan and active-account lookup in discovery — and that binary is absent on Windows, so OpenCode sessions silently never appeared in `agents sessions` there. All three now read through the same runtime-aware node/bun `Database` wrapper the Antigravity parser already uses (`bun:sqlite`/`node:sqlite`, no native addon, no CLI), and the OpenCode transcript query binds the session id as a parameter instead of interpolating it. No behavior change on macOS/Linux. Source: `apps/cli/src/lib/session/parse.ts` (`parseOpenCode`), `apps/cli/src/lib/session/discover.ts` (`scanOpenCodeIncremental`, `getOpenCodeAccount`). (RUSH-1513)
27
+ - **`agents sessions --active --json` now carries the agent's actual decision, not a truncated status line.** A session waiting on you used to collapse everything to a one-line `preview` — an `AskUserQuestion` became the generic `"Asked you a question"` (throwing away the options that are already in the tool input), and a trailing thinking block masked the real turn as `"thinking…"`. The state engine now emits a structured `question` object (`{ text, reason, options: [{ label, description, key }] }`) for every waiting path — `AskUserQuestion` (with each option's 1-based select key), plan review, permission (Approve=`1` / Deny=`esc`), and a trailing prose question — plus a short assistant `tail` for context, and `preview` no longer degrades to `"thinking…"` when a real turn exists. Every consumer (the Factory NEEDS-YOU panel, teams, cloud) now gets the real "what does it want from me" instead of re-deriving it from prose. Verified live: the blocked session in the screenshot now reports `awaitingReason: question` with the real question text. Source: `apps/cli/src/lib/session/state.ts` (`structuredQuestionFromAsk`, `inferActivity`), `apps/cli/src/lib/session/active.ts`. (RUSH-453)
28
+
5
29
  ## 1.20.50
6
30
 
7
31
  - **Distributed agent teams: teammates can now run on different machines across your fleet, not just the box running `teams start`.** A single team can place the backend teammate on a Linux box and the UI teammate on a Mac while one orchestrator still drives the DAG, polls status, and cleans up. One vocabulary, all optional (omit it and teams stay 100% local as before): `teams create --devices a,b,c` (alias `--hosts`) declares a pool the team may auto-schedule onto, `--repo <url|path>` (defaults to the local checkout's `origin`) says how each device gets the code, and `teams add --device X` (alias `--host`) pins one teammate to a host — which needs **no** pool, so "send just one teammate elsewhere" is zero-setup. Placement resolves top-down at launch: explicit `--device` pin → single-device pool (whole team there) → multi-device pool (least-loaded auto-schedule) → local. Remote teammates dispatch over SSH via the existing `agents devices`/host machinery (a third teammate backend beside local and cloud), are monitored by offset-tailing the remote log + `.exit` sentinel, and get the repo auto-provisioned per device (reuse an existing checkout, else clone into `~/.agents/repos/<team>`) with an optional per-teammate git worktree on the host. `teams status`/`teams logs` show each teammate's host and stream its output back with the local mirror capped (~512KB rolling tail) so a 10+-teammate fleet can't blow up the orchestrator. POSIX hosts only in v1 (Windows rejected with a clear message). Source: `apps/cli/src/lib/teams/{scheduler,remoteWorktree,agents,api,supervisor,registry}.ts`, `apps/cli/src/lib/hosts/{progress,passthrough}.ts`, `apps/cli/src/commands/teams.ts`, `apps/cli/docs/teams.md`.
@@ -5,29 +5,12 @@
5
5
  * Shows task name, profile, domains, and tab count; preview pane shows full tab list.
6
6
  */
7
7
  import chalk from 'chalk';
8
+ import { truncate, humanDuration } from '../lib/format.js';
8
9
  import { itemPicker } from '../lib/picker.js';
9
10
  const DOT = chalk.gray(' · ');
10
- function humanDuration(ms) {
11
- const s = Math.floor(ms / 1000);
12
- if (s < 60)
13
- return `${s}s`;
14
- const m = Math.floor(s / 60);
15
- if (m < 60)
16
- return `${m}m`;
17
- const h = Math.floor(m / 60);
18
- const mm = m % 60;
19
- if (h < 24)
20
- return mm ? `${h}h ${mm}m` : `${h}h`;
21
- const d = Math.floor(h / 24);
22
- const hh = h % 24;
23
- return hh ? `${d}d ${hh}h` : `${d}d`;
24
- }
25
11
  function formatAge(ms) {
26
12
  return humanDuration(Date.now() - ms) + ' ago';
27
13
  }
28
- function truncate(s, max) {
29
- return s.length > max ? s.slice(0, max - 1) + '…' : s;
30
- }
31
14
  /** Build the preview pane for a browser task. */
32
15
  export function buildBrowserPreview(item) {
33
16
  const { task, profile } = item;
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { die, relTime, truncate, isJsonMode } from '../lib/format.js';
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
5
  import ora from 'ora';
@@ -35,28 +36,6 @@ function parseSkillRef(raw) {
35
36
  return { id: raw };
36
37
  }
37
38
  /** Print an error message to stderr and exit. */
38
- function die(msg, code = 1) {
39
- console.error(chalk.red(msg));
40
- process.exit(code);
41
- }
42
- /** Format an ISO timestamp as a human-readable relative time string. */
43
- function relTime(iso) {
44
- const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
45
- if (secs < 10)
46
- return 'just now';
47
- if (secs < 60)
48
- return `${secs}s ago`;
49
- if (secs < 3600)
50
- return `${Math.floor(secs / 60)} minutes ago`;
51
- if (secs < 86400)
52
- return `${Math.floor(secs / 3600)} hours ago`;
53
- return `${Math.floor(secs / 86400)} days ago`;
54
- }
55
- function truncate(s, n) {
56
- if (s.length <= n)
57
- return s;
58
- return s.slice(0, n - 1) + '...';
59
- }
60
39
  /** Return a chalk color function appropriate for the given task status. */
61
40
  function statusColor(status) {
62
41
  switch (status) {
@@ -70,9 +49,6 @@ function statusColor(status) {
70
49
  default: return chalk.white;
71
50
  }
72
51
  }
73
- function isJsonMode(opts) {
74
- return Boolean(opts.json) || !process.stdout.isTTY;
75
- }
76
52
  /**
77
53
  * After a `MissingTargetError`, try to resolve the target interactively.
78
54
  * Returns the chosen id, or undefined when no interactive resolution is
@@ -37,5 +37,6 @@ export declare function reconcileScreenshotExt(outPath: string, buf: Buffer): {
37
37
  };
38
38
  export declare function registerComputerCommand(program: Command): void;
39
39
  export declare function registerComputerSubcommands(program: Command): void;
40
+ export declare function buildRestartTaskScript(taskName: string, exeName: string): string;
40
41
  export { resolveHelperExec as resolveHelperPath };
41
42
  export { resolveSocketPath };
@@ -4,7 +4,9 @@ import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
6
  import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer-rpc.js';
7
- import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, } from '../lib/ssh-tunnel.js';
7
+ import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/ssh-tunnel.js';
8
+ import { sshExec } from '../lib/ssh-exec.js';
9
+ import { encodePowershell } from '../lib/hosts/remote-cmd.js';
8
10
  import { registerActionCommands, withClient, unwrap, pickTarget } from './computer-actions.js';
9
11
  import { runComputerLoop } from '../lib/computer/loop.js';
10
12
  import { makeVerbDispatcher } from '../lib/computer/dispatch.js';
@@ -18,9 +20,10 @@ const COMPUTER_HELP_GROUPS = [
18
20
  { title: 'Interact', names: ['launch', 'raise', 'click', 'right-click', 'type', 'type-text', 'key', 'drag', 'scroll', 'ax-action', 'focus', 'wait'] },
19
21
  ];
20
22
  // Subcommands that manage the `--host` remote path themselves (provisioning /
21
- // tunnel lifecycle). Every other `--host`-bearing subcommand is a plain verb
22
- // that just needs the TCP endpoint hydrated before it runs.
23
- const REMOTE_LIFECYCLE = new Set(['setup', 'start', 'stop']);
23
+ // tunnel lifecycle, or daemon-state reporting that must degrade gracefully
24
+ // when no tunnel is recorded). Every other `--host`-bearing subcommand is a
25
+ // plain verb that just needs the TCP endpoint hydrated before it runs.
26
+ const REMOTE_LIFECYCLE = new Set(['setup', 'start', 'stop', 'status', 'reload']);
24
27
  /**
25
28
  * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
26
29
  * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
@@ -111,8 +114,13 @@ export function registerComputerSubcommands(program) {
111
114
  function registerStatusCommand(program) {
112
115
  program
113
116
  .command('status')
114
- .description('Report install state, daemon state, and Accessibility trust')
115
- .action(async () => {
117
+ .description('Report install state, daemon state, and Accessibility trust — or a remote Windows daemon with --host')
118
+ .option('--host <device>', 'Report the remote Windows daemon (tunnel + liveness) instead of the local helper')
119
+ .action(async (opts) => {
120
+ if (opts.host) {
121
+ await reportRemoteStatus(opts.host);
122
+ return;
123
+ }
116
124
  const socketPath = resolveSocketPath();
117
125
  const installed = fs.existsSync(HELPER_APP_DEST);
118
126
  const socketUp = fs.existsSync(socketPath);
@@ -159,6 +167,108 @@ function registerStatusCommand(program) {
159
167
  }
160
168
  });
161
169
  }
170
+ // status --host: the local checks (app install, launchd socket, policy files)
171
+ // are macOS concepts — a remote Windows daemon is reported from what actually
172
+ // exists for it: the recorded tunnel and a live trust_status probe through it.
173
+ async function reportRemoteStatus(host) {
174
+ console.log(`host: ${host}`);
175
+ const state = readRemoteState(host);
176
+ if (!state) {
177
+ console.log('tunnel: none');
178
+ console.log('daemon: unknown (no tunnel to probe through)');
179
+ console.log('');
180
+ console.log(`Run: agents computer start --host ${host}`);
181
+ process.exit(1);
182
+ }
183
+ console.log(`tunnel: 127.0.0.1:${state.localPort} -> ${state.target} (127.0.0.1:${state.remotePort})`);
184
+ hydrateRemoteEnvFromState(host);
185
+ try {
186
+ const client = openComputerClient();
187
+ try {
188
+ const r = await client.call('trust_status');
189
+ if (r.error) {
190
+ console.error(`error: ${r.error.code}: ${r.error.message}`);
191
+ process.exit(1);
192
+ }
193
+ console.log('daemon: running');
194
+ console.log(`trust: ${r.result?.trusted ? 'granted' : 'denied'} (Windows UIAutomation needs no per-app grant)`);
195
+ if (typeof r.result?.pid === 'number')
196
+ console.log(`pid: ${r.result.pid}`);
197
+ if (typeof r.result?.path === 'string' && r.result.path)
198
+ console.log(`exe: ${r.result.path}`);
199
+ }
200
+ finally {
201
+ await client.close();
202
+ }
203
+ }
204
+ catch (err) {
205
+ console.log('daemon: unreachable');
206
+ console.log(` ${err.message}`);
207
+ console.log('');
208
+ console.log(`Run: agents computer start --host ${host}`);
209
+ process.exit(1);
210
+ }
211
+ }
212
+ // PowerShell to bounce the remote daemon: kill the running exe (tolerating
213
+ // "not running"), then start the LOGON scheduled task that owns its
214
+ // lifecycle. Pure so tests can assert the exact script (mirrors the
215
+ // ssh-tunnel script builders).
216
+ export function buildRestartTaskScript(taskName, exeName) {
217
+ const procName = exeName.replace(/\.exe$/i, '');
218
+ return [
219
+ `$ErrorActionPreference = 'Stop'`,
220
+ `Stop-Process -Name '${procName}' -Force -ErrorAction SilentlyContinue`,
221
+ `Start-ScheduledTask -TaskName '${taskName}'`,
222
+ `Write-Output 'restarted'`,
223
+ ].join('; ');
224
+ }
225
+ // reload --host: the Windows daemon has no policy file to re-read (it
226
+ // enforces no allow-list — see TrustStatus in native/computer-win/Rpc.cs), so
227
+ // reload means bounce the daemon via its scheduled task — the way to pick up
228
+ // a freshly pushed exe — then prove it answers through the recorded tunnel.
229
+ async function reloadRemoteHelper(host) {
230
+ const { target } = await resolveRemoteDevice(host);
231
+ const script = buildRestartTaskScript(REMOTE_TASK_NAME, WIN_HELPER_EXE);
232
+ const res = sshExec(target, `powershell -NoProfile -NonInteractive -EncodedCommand ${encodePowershell(script)}`, { timeoutMs: 60_000 });
233
+ if (res.code !== 0) {
234
+ const msg = (res.stderr || res.stdout || '').trim();
235
+ console.error(`restart failed on ${target}${res.timedOut ? ' (timed out)' : ''}${msg ? `: ${msg}` : ''}`);
236
+ process.exit(1);
237
+ }
238
+ console.log(`task: restarted "${REMOTE_TASK_NAME}" on ${target}`);
239
+ const state = readRemoteState(host);
240
+ if (!state) {
241
+ console.log(`(no tunnel recorded — run \`agents computer start --host ${host}\` to drive it)`);
242
+ return;
243
+ }
244
+ hydrateRemoteEnvFromState(host);
245
+ // The relaunched daemon needs a beat to rebind its port; poll through the
246
+ // tunnel until it answers.
247
+ const deadline = Date.now() + 15_000;
248
+ let lastErr = '';
249
+ while (Date.now() < deadline) {
250
+ try {
251
+ const client = openComputerClient();
252
+ try {
253
+ const r = await client.call('trust_status');
254
+ if (!r.error && r.result) {
255
+ console.log(`reloaded: daemon answering (pid ${r.result.pid ?? '?'})`);
256
+ return;
257
+ }
258
+ lastErr = r.error ? `${r.error.code}: ${r.error.message}` : 'empty result';
259
+ }
260
+ finally {
261
+ await client.close();
262
+ }
263
+ }
264
+ catch (err) {
265
+ lastErr = err.message;
266
+ }
267
+ await sleep(500);
268
+ }
269
+ console.error(`daemon did not answer within 15s after restart${lastErr ? ` (${lastErr})` : ''}`);
270
+ process.exit(1);
271
+ }
162
272
  // run — the embedded observe -> act -> verify agent loop. A reasoning model
163
273
  // (Claude API by default, or any Anthropic-shaped endpoint via --base-url for
164
274
  // Ollama / vLLM / LiteLLM) drives the EXISTING computer verbs as tools over the
@@ -560,8 +670,19 @@ function registerStartCommand(program) {
560
670
  function registerReloadCommand(program) {
561
671
  program
562
672
  .command('reload')
563
- .description('Reload the allow-list policy from ~/.agents/permissions/groups/ (SIGHUP the daemon)')
564
- .action(async () => {
673
+ .description('Reload the allow-list policy (SIGHUP the local daemon) — or restart a remote Windows daemon with --host')
674
+ .option('--host <device>', 'Restart the remote Windows daemon (its scheduled task) instead of SIGHUPing the local one')
675
+ .action(async (opts) => {
676
+ if (opts.host) {
677
+ try {
678
+ await reloadRemoteHelper(opts.host);
679
+ }
680
+ catch (err) {
681
+ console.error(`error: ${err.message}`);
682
+ process.exit(1);
683
+ }
684
+ return;
685
+ }
565
686
  const socketPath = resolveSocketPath();
566
687
  if (!fs.existsSync(socketPath)) {
567
688
  console.error(`daemon not running (no socket at ${socketPath})`);
@@ -11,6 +11,7 @@ import { setHelpSections } from '../lib/help.js';
11
11
  import { parseLoopInterval } from '../lib/loop.js';
12
12
  import { AGENTS } from '../lib/agents.js';
13
13
  import { recordDispatchedRun } from '../lib/audit/log.js';
14
+ import { warnUnpushedWork, shouldWarnUnpushed } from '../lib/warn-unpushed.js';
14
15
  import * as fs from 'fs';
15
16
  import * as path from 'path';
16
17
  import * as os from 'os';
@@ -211,7 +212,7 @@ export function registerRunCommand(program) {
211
212
  .option('--secrets-keys <keys>', 'Inject only this comma-separated subset of keys from --secrets bundles (e.g. KEY1,KEY2). Missing keys are an error. Applies to all --secrets bundles on this run.')
212
213
  .option('--allow-expired', 'Inject secrets even if their expiry date has passed (overrides the pre-run expiry abort).')
213
214
  .option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
214
- .option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
215
+ .option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude and Codex, repeatable)', (val, prev) => [...prev, val], [])
215
216
  .option('--json', 'Stream events as JSON lines (for parsing by other tools)')
216
217
  .option('--quiet', 'Suppress preamble (rotation banner, "Running:" line). Useful when piping JSON events to a parser.', false)
217
218
  .option('--headless', 'Force headless mode. Auto-enabled when a prompt is provided; pass explicitly to stay headless with no prompt (reads the prompt from stdin).', false)
@@ -245,6 +246,12 @@ export function registerRunCommand(program) {
245
246
  // `--on` and `--computer` are hidden aliases of `--host` — same behavior.
246
247
  runCmd.addOption(new Option('--on <name>', 'Alias of --host.').hideHelp());
247
248
  runCmd.addOption(new Option('--computer <name>', 'Alias of --host.').hideHelp());
249
+ // Required for the documented `agents run <agent> [prompt] -- <native flags>`
250
+ // passthrough: commander >=13 rejects excess operands by default, so any
251
+ // post-`--` token died with "too many arguments" before the action ran. The
252
+ // action re-derives the `--` boundary from rawArgs and still errors on excess
253
+ // operands that are NOT behind `--`.
254
+ runCmd.allowExcessArguments(true);
248
255
  setHelpSections(runCmd, {
249
256
  examples: `
250
257
  # Headless, read-only: investigate or summarize without writing files
@@ -293,10 +300,25 @@ export function registerRunCommand(program) {
293
300
  `,
294
301
  });
295
302
  runCmd.action(async (agentSpec, prompt, options, command) => {
296
- // Capture everything after -- as passthrough args forwarded verbatim to the underlying CLI.
297
- // Use command.args (all positional strings) and strip the declared positional args from the front.
298
- const declaredArgCount = prompt !== undefined ? 2 : 1;
299
- const passthroughArgs = command.args.slice(declaredArgCount);
303
+ // Capture everything after -- as passthrough args forwarded verbatim to the
304
+ // underlying CLI. Commander strips the literal `--` and folds what follows
305
+ // into the positional operands (so `agents run codex -- --yolo` would parse
306
+ // `--yolo` as the PROMPT) — recover the boundary from rawArgs instead of
307
+ // operand counts. Excess operands not behind `--` are still an error (an
308
+ // unquoted prompt must not silently become agent flags).
309
+ const rawArgs = process.argv;
310
+ const separatorIdx = rawArgs.indexOf('--');
311
+ const passthroughArgs = separatorIdx === -1 ? [] : rawArgs.slice(separatorIdx + 1);
312
+ const operandsBeforeSeparator = command.args.length - passthroughArgs.length;
313
+ if (operandsBeforeSeparator > 2) {
314
+ console.error(chalk.red(`Too many arguments for 'run'. Quote the prompt ("fix the bug"), and put agent-native flags after -- (agents run codex -- --yolo).`));
315
+ process.exit(1);
316
+ }
317
+ if (prompt !== undefined && operandsBeforeSeparator < 2) {
318
+ // The token commander assigned to [prompt] came from behind `--` — it is
319
+ // a native flag, not a prompt. Run interactively.
320
+ prompt = undefined;
321
+ }
300
322
  // --lease: invent a disposable cloud box for this run (via crabbox), run
301
323
  // the agent there, then tear it down. Unlike --host, nothing is registered.
302
324
  if (options.lease) {
@@ -342,7 +364,7 @@ export function registerRunCommand(program) {
342
364
  secretsBundle: process.env.AGENTS_LEASE_SECRETS_BUNDLE,
343
365
  keep: options.keepBox,
344
366
  });
345
- console.error(chalk.gray(toreDown ? `Box ${box.slug} destroyed.` : `Box ${box.slug} kept${box.ip ? ` (${box.ip})` : ''}. Stop it: crabbox stop --id ${box.slug}`));
367
+ console.error(chalk.gray(toreDown ? `Box ${box.slug} destroyed.` : `Box ${box.slug} kept${box.ip ? ` (${box.ip})` : ''}. Stop it: crabbox stop ${box.slug}`));
346
368
  process.exit(exitCode === null ? 1 : exitCode);
347
369
  }
348
370
  catch (err) {
@@ -568,6 +590,9 @@ export function registerRunCommand(program) {
568
590
  cwd: resumeExec.cwd ?? process.cwd(),
569
591
  exitCode: resumeExit,
570
592
  });
593
+ // A resumed loop is always headless; surface any commits it left unpushed.
594
+ if (shouldWarnUnpushed(resumeExec.mode ?? 'auto', false))
595
+ await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
571
596
  process.exit(resumeExit);
572
597
  }
573
598
  const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
@@ -1279,6 +1304,9 @@ export function registerRunCommand(program) {
1279
1304
  // Governance chokepoint (#347): the --acp path exits here, bypassing
1280
1305
  // the normal finalize below — record its one audit entry.
1281
1306
  recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode });
1307
+ // ACP headless run always has a prompt; surface any unpushed commits.
1308
+ if (shouldWarnUnpushed(mode, false))
1309
+ await warnUnpushedWork(cwd);
1282
1310
  process.exit(exitCode);
1283
1311
  }
1284
1312
  catch (err) {
@@ -1435,6 +1463,9 @@ export function registerRunCommand(program) {
1435
1463
  // the normal finalize below — record its one audit entry.
1436
1464
  const loopExit = loopExitCode(result.stoppedBy);
1437
1465
  recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode: loopExit });
1466
+ // A loop is always headless; surface any commits it left unpushed.
1467
+ if (shouldWarnUnpushed(mode, false))
1468
+ await warnUnpushedWork(cwd);
1438
1469
  process.exit(loopExit);
1439
1470
  }
1440
1471
  catch (err) {
@@ -1462,6 +1493,13 @@ export function registerRunCommand(program) {
1462
1493
  }
1463
1494
  cleanupWorkflowMcpConfig();
1464
1495
  cleanupWorkflowSubagents();
1496
+ // Surface committed-but-unpushed work a headless writable run left
1497
+ // behind, so it isn't silently stranded in a worktree. Advisory only,
1498
+ // never throws; skipped for interactive runs (the human sees the shell)
1499
+ // and read-only plan mode (can't commit).
1500
+ if (shouldWarnUnpushed(mode, resolveInteractive(execOptions))) {
1501
+ await warnUnpushedWork(cwd);
1502
+ }
1465
1503
  // Governance chokepoint (#347): every dispatched run finalizes here.
1466
1504
  // ONE tamper-evident audit record per run — non-fatal by contract.
1467
1505
  recordDispatchedRun({ agent: ranAgent, version: ranVersion ?? 'unknown', mode, cwd, exitCode });
@@ -1470,6 +1508,11 @@ export function registerRunCommand(program) {
1470
1508
  catch (err) {
1471
1509
  cleanupWorkflowMcpConfig();
1472
1510
  cleanupWorkflowSubagents();
1511
+ // An agent that committed then crashed is the case where stranded work
1512
+ // matters most — warn before exiting the error path too.
1513
+ if (shouldWarnUnpushed(mode, resolveInteractive(execOptions))) {
1514
+ await warnUnpushedWork(cwd);
1515
+ }
1473
1516
  console.error(chalk.red(`Failed to execute ${agent}: ${err.message}`));
1474
1517
  process.exit(1);
1475
1518
  }
@@ -1,13 +1,10 @@
1
1
  import chalk from 'chalk';
2
+ import { die } from '../lib/format.js';
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
5
  import { homedir } from 'os';
5
6
  import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
6
7
  import { insertTask } from '../lib/cloud/store.js';
7
- function die(msg, code = 1) {
8
- console.error(chalk.red(msg));
9
- process.exit(code);
10
- }
11
8
  function requireFactoryUrl() {
12
9
  const url = process.env.FACTORY_FLOOR_URL;
13
10
  if (!url) {
@@ -17,6 +17,7 @@ import * as fs from 'fs';
17
17
  import * as os from 'os';
18
18
  import * as path from 'path';
19
19
  import chalk from 'chalk';
20
+ import { truncate, termLink } from '../lib/format.js';
20
21
  import * as yaml from 'yaml';
21
22
  import { AGENTS, getCliState, resolveAgentName } from '../lib/agents.js';
22
23
  import { supports } from '../lib/capabilities.js';
@@ -1216,12 +1217,6 @@ function readFirstProseLine(p) {
1216
1217
  return '';
1217
1218
  }
1218
1219
  // ─── OSC-8 + path helpers ────────────────────────────────────────────────────
1219
- function termLink(text, filePath) {
1220
- if (!filePath || !process.stdout.isTTY)
1221
- return text;
1222
- const url = `file://${filePath}`;
1223
- return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
1224
- }
1225
1220
  function linkTarget(p) {
1226
1221
  if (!p)
1227
1222
  return '';
@@ -1271,8 +1266,3 @@ function safeCountSessions(agent) {
1271
1266
  return 0;
1272
1267
  }
1273
1268
  }
1274
- function truncate(s, n) {
1275
- if (s.length <= n)
1276
- return s;
1277
- return s.slice(0, n - 1) + '…';
1278
- }
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { truncate } from '../lib/format.js';
2
3
  import ora from 'ora';
3
4
  import { checkbox } from '@inquirer/prompts';
4
5
  import { capableAgents, isCapable } from '../lib/capabilities.js';
@@ -764,7 +765,7 @@ function buildMcpRows(opts) {
764
765
  const displayCommand = declaredCommand || firstCommand;
765
766
  rows.push({
766
767
  name,
767
- description: displayCommand ? truncateString(displayCommand, 60) : '',
768
+ description: displayCommand ? truncate(displayCommand, 60) : '',
768
769
  extra: source,
769
770
  targets,
770
771
  buildDetail: () => formatMcpDetail(name, source, centralConfig, manifestConfig, displayCommand, targets),
@@ -811,8 +812,3 @@ function formatMcpDetail(name, source, centralConfig, manifestConfig, command, t
811
812
  lines.push(buildTargetsSection(targets));
812
813
  return lines.join('\n');
813
814
  }
814
- function truncateString(s, max) {
815
- if (s.length <= max)
816
- return s;
817
- return s.slice(0, max - 1) + '…';
818
- }
@@ -1,13 +1,10 @@
1
1
  import chalk from 'chalk';
2
+ import { die } from '../lib/format.js';
2
3
  import { getActiveSessions } from '../lib/session/active.js';
3
4
  import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
4
5
  import { resolveProvider } from '../lib/cloud/registry.js';
5
6
  import { mailboxDir, enqueue } from '../lib/mailbox.js';
6
7
  import { resolveMessageTarget } from '../lib/mailbox-target.js';
7
- function die(msg, code = 1) {
8
- console.error(chalk.red(msg));
9
- process.exit(code);
10
- }
11
8
  export function registerMessageCommand(program) {
12
9
  program
13
10
  .command('message <target> <text>')
@@ -7,7 +7,7 @@
7
7
  * through a standard agent CLI with no local proxy.
8
8
  */
9
9
  import chalk from 'chalk';
10
- import * as fs from 'fs';
10
+ import { readStdinSync } from '../lib/format.js';
11
11
  import { spawn } from 'child_process';
12
12
  import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
13
13
  import { getPreset, listPresets, expandPreset } from '../lib/profiles-presets.js';
@@ -41,23 +41,6 @@ async function promptForSecret(message) {
41
41
  return await password({ message, mask: true });
42
42
  }
43
43
  /** Read all available data from stdin synchronously, trimmed. */
44
- function readStdinSync() {
45
- const chunks = [];
46
- const buf = Buffer.alloc(65536);
47
- while (true) {
48
- let bytesRead;
49
- try {
50
- bytesRead = fs.readSync(0, buf, 0, buf.length, null);
51
- }
52
- catch {
53
- break;
54
- }
55
- if (bytesRead === 0)
56
- break;
57
- chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
58
- }
59
- return Buffer.concat(chunks).toString('utf-8').trim();
60
- }
61
44
  /** Ensure a provider API key exists in keychain, prompting or reading stdin if missing. */
62
45
  async function ensureProviderToken(provider, signupUrl, fromStdin) {
63
46
  const item = keychainItemName(provider);