@phnx-labs/agents-cli 1.20.42 → 1.20.44

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 (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +46 -8
  4. package/dist/commands/hosts.js +14 -9
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/sessions.d.ts +6 -5
  10. package/dist/commands/sessions.js +50 -22
  11. package/dist/commands/teams.js +43 -5
  12. package/dist/lib/browser/chrome.d.ts +22 -0
  13. package/dist/lib/browser/chrome.js +53 -13
  14. package/dist/lib/browser/service.js +13 -0
  15. package/dist/lib/daemon.js +34 -9
  16. package/dist/lib/exec.d.ts +15 -0
  17. package/dist/lib/exec.js +83 -6
  18. package/dist/lib/hosts/dispatch.d.ts +5 -0
  19. package/dist/lib/hosts/dispatch.js +4 -0
  20. package/dist/lib/hosts/logs.d.ts +14 -5
  21. package/dist/lib/hosts/logs.js +39 -13
  22. package/dist/lib/hosts/session-index.js +1 -0
  23. package/dist/lib/hosts/tasks.d.ts +15 -0
  24. package/dist/lib/hosts/tasks.js +16 -0
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +11 -6
  27. package/dist/lib/rotate.js +25 -11
  28. package/dist/lib/session/active.d.ts +8 -0
  29. package/dist/lib/session/active.js +17 -1
  30. package/dist/lib/session/db.d.ts +11 -0
  31. package/dist/lib/session/db.js +84 -19
  32. package/dist/lib/session/discover.js +5 -1
  33. package/dist/lib/session/remote.d.ts +4 -6
  34. package/dist/lib/session/remote.js +5 -12
  35. package/dist/lib/session/run-names.d.ts +32 -0
  36. package/dist/lib/session/run-names.js +63 -0
  37. package/dist/lib/session/types.d.ts +8 -0
  38. package/dist/lib/shims.d.ts +1 -1
  39. package/dist/lib/shims.js +17 -3
  40. package/dist/lib/teams/agents.js +16 -7
  41. package/dist/lib/tmux/session.d.ts +40 -0
  42. package/dist/lib/tmux/session.js +92 -0
  43. package/dist/lib/usage.d.ts +5 -3
  44. package/dist/lib/usage.js +5 -3
  45. package/dist/lib/versions.d.ts +54 -1
  46. package/dist/lib/versions.js +138 -1
  47. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.44
6
+
7
+ - **Every `logs` command is concise by default; the token-heavy raw dump is now opt-in behind `--full`.** Agents that spin up agents on other machines or add teammates were pulling whole transcripts just to glance at status — `agents logs <session>` printed the full markdown transcript, and `agents hosts logs` / `agents teams logs` / `agents routines logs` each `cat`'d their entire captured stdout, because each subsystem had hand-rolled its own "cat the log" verb over its own storage. All four now default to a bounded, concise view, with `-m/--full` for the raw log: `agents logs <session>` renders the same summary digest as `agents sessions <id>` (a real session shrank 92% — 29.9 KB → 2.6 KB); `agents routines logs <name>` shows a status header + the extracted report (a real run shrank 99.5% — 386 KB → 1.8 KB), falling back to a bounded stdout tail when no report was extracted; `agents teams logs <teammate>` renders the teammate's session summary (its agentId **is** the session id), with `-n <lines>` / `--full` for raw stdout; `agents hosts logs <id>` shows a bounded tail of the captured stdout (`tailLines`, with a "… N earlier lines hidden — pass --full" note) instead of the whole log. `renderSessionLog` now takes a mode and defaults to `'summary'`; `agents sessions <id>` was already summary-by-default and is unchanged. Regression-tested: `tailLines` truncation/elision math (`hosts/logs.test.ts`) and `formatRunDuration` human-time formatting (`routines-logs.test.ts`). Source: `apps/cli/src/commands/{logs,sessions,hosts,teams,routines}.ts`, `apps/cli/src/lib/hosts/logs.ts`. Scoped follow-up (not in this PR): host-task and sandboxed-routine runs write their real transcript on the remote / in an overlay HOME, so `logs` can't yet resolve them to the full `renderSummary` — making those runs discoverable is a separate change; until then the bounded tail / extracted report is the safe concise default.
8
+ - **The daemon now self-heals the `pane-died` hook on already-running `agents run` sessions.** The v1.20.42 fix that stops exiting a split from kicking you out of tmux is installed once, at session creation — so sessions already alive under the long-lived shared tmux server keep the old, unconditional `detach-client` hook until they exit or the server is recycled. On a machine that's never "between sessions," that meant hand-repairing live sessions. The daemon now runs `reconcileSessionHooks()` ~20s after startup and every ~5 min: it walks the managed `ag-` sessions on the shared socket and retrofits the `#{hook_pane}`-guarded hook onto any whose hook predates the current schema. It is strictly **non-destructive — `set-hook` only, never a `kill-pane` or `detach-client`** — so it is safe to run against sessions you're attached to; a per-session `@ag_hook_schema` marker makes steady-state a no-op. The hook string is now built in one place (`agentPaneDiedHook`) shared by the spawn-wrap and the reconcile so they can't drift. Source: `apps/cli/src/lib/tmux/session.ts`, `apps/cli/src/lib/daemon.ts`, `apps/cli/src/lib/exec.ts`.
9
+ - **NEW: `agents run` self-heals a gutted install instead of crashing with `ENOENT`.** The recurring failure: an npm agent whose native binary ships as an optional per-arch dependency (codex → `@openai/codex-<platform>`) can have that tarball extract **partially** — the platform package's `package.json` lands, its `vendor/<triple>/…/codex` binary does not (an interrupted or concurrently-raced `agents add` into the same version dir). The CLI's wrapper `require.resolve`s the platform package, finds the `package.json`, and sails straight past its own "missing optional dependency" guard into a `spawn(binaryPath)` that dies with a raw `ENOENT`. `agents run` now probes the version it's about to launch and, if the binary can't run, **repairs it in place** (a *clean* reinstall — the partial `node_modules` is wiped first, because npm treats the present-but-gutted platform package as already installed and would otherwise skip re-fetching it), then falls back to another installed version that launches (re-pinning it as the default so the shim path heals too), then to installing `latest` — only erroring if nothing can be made runnable. `installVersion` gained a `{ clean }` option for the wipe-then-reinstall. Source: `apps/cli/src/lib/versions.ts` (`ensureAgentRunnable`), `apps/cli/src/commands/exec.ts`.
10
+ - **Fix: a broken agent install no longer launches into a silent `[detached]` — the real crash is surfaced.** When an interactive `ag run <agent>` wrapped the agent in tmux and the agent died the instant it spawned (e.g. a gutted install crashing with `spawn … ENOENT`, a bad flag, a startup crash), the `pane-died` hook detached the client before you could read anything — you got a bare `[detached (from session …)]` with zero indication of why. `runInTmux` now recaps the dead pane's last output (read from scrollback via `capture-pane -S -200`, since the pane's visible screen is just the "Pane is dead" banner) plus the exit code to stderr, and points you at `--no-tmux`. Fast failures (dead before attach) always recap; a post-attach nonzero exit recaps too (a clean exit or a manual detach stays quiet). Source: `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/tmux/session.test.ts`.
11
+ - **NEW: `--no-tmux` / `--disable-tmux` on `agents run`.** The interactive tmux wrapper (which gives `%pane` addressing + re-attach) already had an opt-out, but it was hidden behind the opaquely-named `--raw`. `--no-tmux` (and its alias `--disable-tmux`) spawn the agent directly with full stdio inherited — the fastest way to see an agent's real startup output when a launch is failing. Same effect as `--raw` and `AGENTS_NO_TMUX=1`. Source: `apps/cli/src/commands/exec.ts`.
12
+ - **Fix: `agents add <agent>@<version>` no longer records a gutted install as healthy (root cause of the ENOENT crash + a broken default pin).** npm packages that ship their native binary via an optional per-arch dependency (e.g. codex → `@openai/codex-<platform>`) can land the JS wrapper at `node_modules/.bin/<cli>` while the real platform binary is missing (interrupted install, omitted optional dep, `--ignore-scripts`). `getBinaryPath()` only checked the wrapper, so the broken version read as installed, got pinned as the default, and got picked to run — then died with ENOENT. `installVersion` now probes `<binary> --version` (under the version's isolated HOME) after install and **fails the install** if the binary can't launch, so a broken version is never silently pinned. The check is deliberately narrow — only the missing-binary signature (`ENOENT`/"no such file"/"command not found") fails it; a plain nonzero exit or a timeout is treated as healthy, so a well-behaved agent that dislikes `--version` is never false-failed. Source: `apps/cli/src/lib/versions.ts`, `apps/cli/src/lib/versions-integrity.test.ts`.
13
+ - **Security fix: the routines daemon log no longer leaks GitHub / AWS / npm tokens.** `daemon.ts` carried its own private `redactSecrets` (used by every `log()` write to `logs.jsonl`) that predated and diverged from the canonical `redact.ts` — it caught `sk-`, `eyJ…`, `Bearer …`, and a narrow `NAME=value` list, but **not** `ghp_` (GitHub PAT), `AKIA…` (AWS access key), or `npm_` (npm token), so any of those appearing in a daemon message (a git push URL, a bundle-env dump, an error string) was written to the log in the clear. The private copy is deleted; `log()` now routes through the canonical `redactSecrets` in `redact.ts`, which covers all of those classes with a stronger quote-aware `NAME=value` pattern. The one pattern the daemon copy had and the canonical lacked — `Bearer <token>` — is added to `redact.ts`, so the shared redactor (also used by session-transcript export in `session/render.ts`) is now a strict superset. New `redact.test.ts` pins every token class as a regression guard. Source: `apps/cli/src/lib/daemon.ts`, `apps/cli/src/lib/redact.ts`, `apps/cli/src/lib/redact.test.ts`.
14
+
15
+ ## 1.20.43
16
+
17
+ - **NEW: `agents run --name <slug>` — a durable, human/agent-friendly handle for any run.** An agent that dispatches another agent had no cheap status handle: the host-task id was never even printed (the `--no-follow` tip showed a literal `<id>` placeholder), and only Claude's session id is known up front (pre-minted `--session-id`) — every other agent's id is discovered later by scanning transcripts, so callers fell back to `agents logs`, which dumps the raw, token-heavy transcript. `--name` is chosen at launch, agent-agnostic, and stored on the structures that already back these views: a first-class `name` column on `sessions.db` (schema v9, additive, no rescan) parallel to `label` — `agents sessions <ref>` resolves against **both** name and label; the HostTask sidecar (forwarded to the remote run, so `agents hosts ps` gains a NAME column and `agents hosts logs <name>` resolves by name); and a run-name sidecar (`~/.agents/.cache/run-names/`) that joins a local run's name onto the index by id every scan via `syncNames` — the same idempotent pattern as `/rename` label sync. The `name` column is deliberately left out of the upsert `ON CONFLICT … SET` clause, so a discovery rescan can never null an existing name (regression-tested in `db.names.test.ts`). Omitting `--name` is a strict no-op: `name` stays unset and every id-based path is unchanged. The `--no-follow` dispatch tip now prints the real handle and steers to the compact `agents sessions` digest over the raw log. Source: `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/session/{db,run-names,discover}.ts`, `apps/cli/src/lib/hosts/{dispatch,tasks}.ts`.
18
+ - **New terminals (and teammates) no longer launch into a rate-limited account; `balanced` is now the default run strategy.** Two coupled fixes. (1) A bare `agents run <agent>` — every new agent terminal the extension spawns, and every non-version-pinned `agents teams add`/`start` teammate, since both route through bare `agents run` — used to default to the `available` strategy, which *prefers the pinned default version when it looks healthy*. But "healthy" was judged by the router's `getRoutingUsedPercent`, which **excluded the 5-hour session window** and looked at weekly usage only. So a session-maxed account with weekly headroom (e.g. session 100% / week 60%) was deemed eligible and kept getting launched — while `agents view` showed it "rate-limited" (its badge, `deriveUsageStatusFromSnapshot`, *counts* the session window). The router and the badge disagreed. Now `hasUsageAvailable` shares the badge's exact signal: an account maxed on **any** blocking window (session or weekly) is ineligible and skipped by both `available` and `balanced` — you never spin up an agent on an account that can't serve the next request. Capacity *weighting* still ranks eligible accounts by weekly headroom, so a brief session spike doesn't distort long-run routing. (2) The default strategy is now `balanced` (was `available`): a bare run spreads load across all healthy accounts by remaining headroom instead of sticking to the pinned default. Override per-workspace with `run.<agent>.strategy` in `agents.yaml`, or per-invocation with `--strategy` / `-b`. Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/lib/usage.ts`, `apps/cli/src/commands/exec.ts`.
19
+ - **[browser] Logins survive browser restarts: sandboxed profiles keep memory-only session cookies, without restoring tabs.** Sites that issue login cookies with `expires=-1` (idealista, many banking/classifieds sites) logged the profile out on every browser restart, because Chromium purges memory-only session cookies at startup unless the session-restore preference is set — a constraint that had already leaked into agent designs as "sessions can't survive restarts". Every launch now pins `session.restore_on_startup: 1` ("continue where you left off") in the profile's `Default/Preferences`, which is the switch Chromium's cookie purge actually keys off — and pairs it with `--no-startup-window` so the *visible* side of restore never happens: no window exists at startup for restore to fill, no ghost tabs from the last task reopen, and the task flow creates its own tab over CDP exactly as before. Verified live on Windows/Comet: a memory-only cookie planted pre-restart was still present after a full stop/start, with OS-level window enumeration confirming a single window and zero restored tabs. The Preferences patch runs pre-spawn (browser down, so Chromium can't overwrite it on exit), stamps the profile name only on first launch, skips malformed files untouched, and is a no-op when already set. Electron profiles keep the old name-only seeding — they manage their own storage and need their startup window (the CDP driver binds to it). Bare `agents browser start` (no `--url`) recreates the old startup-window affordance by opening a blank page target when none exists, unregistered on the task like the startup window always was. Server-side session TTLs still apply — this removes the restart logout, not the site's own expiry. Source: `apps/cli/src/lib/browser/chrome.ts` (`ensureProfilePreferences`, launch args), `apps/cli/src/lib/browser/service.ts`.
20
+ - **Security fix: `agents sessions --host <target>` no longer accepts a leading-dash target (SSH argv-flag smuggling).** `session/remote.ts` carried its own copy of `assertValidSshTarget` that omitted the `host.startsWith('-')` guard every other SSH path enforces, so a bare flag like `-l` or `-F/path` — which passes the character allowlist — was handed straight to `ssh` as an argument (`-oProxyCommand=…`-class injection) before any connection. The duplicate validator (and its `SSH_TARGET_RE`) is deleted; `runRemoteSessions` now routes through the canonical `assertValidSshTarget` in `ssh-exec.ts`, whose dash guard is already regression-tested (`ssh-exec.test.ts`). Source: `apps/cli/src/lib/session/remote.ts`.
21
+
5
22
  ## 1.20.42
6
23
 
7
24
  - **Fix: exiting a split pane inside an interactive `ag run` session kicked you out of tmux entirely.** When you split the window of an interactive agent session (`ag run claude`) with Ctrl-b `"`/`%` and then `exit`ed *your* split, the whole tmux client detached and dumped you back to the parent shell — even though the agent was still running in the other pane. Cause: `runInTmux` installed a session-wide `pane-died` hook (`detach-client`) meant to fire only when the AGENT pane exits (so the attach returns and the exit status is read), but with no `#{hook_pane}` guard it fired for *any* pane's death. The hook is now scoped to the agent pane; a user split that exits is closed in place (`kill-pane`, no lingering dead husk) and the agent keeps running full-window. Source: `apps/cli/src/lib/exec.ts`, `apps/cli/src/lib/tmux/session.test.ts`.
package/README.md CHANGED
@@ -243,7 +243,8 @@ agents hosts check gpu-box # reachable? which agents-cli version?
243
243
 
244
244
  # Run there instead of locally
245
245
  agents run claude --host gpu-box "profile this build" # follows live by default
246
- agents logs --host gpu-box # pick a dispatched run and view its log
246
+ agents logs --host gpu-box # pick a dispatched run concise summary by default
247
+ agents logs <id> --full # the full raw transcript / stdout (token-heavy)
247
248
  agents logs <id> -f # re-attach to a running one and follow
248
249
  agents view claude --host gpu-box # inspect the remote install
249
250
  agents sync --host gpu-box # make the remote machine current
@@ -274,7 +275,7 @@ agents teams start auth-feature # Fires teammates whose deps are done
274
275
  agents teams status auth-feature # Who's working, what they changed, what they said
275
276
  ```
276
277
 
277
- Teammates run detached -- close your terminal, they keep working. Check in with `teams status`, read full output with `teams logs <name>`, clean up with `teams disband`.
278
+ Teammates run detached -- close your terminal, they keep working. Check in with `teams status`, glance at a teammate's summary with `teams logs <name>` (add `--full` for the raw output), clean up with `teams disband`.
278
279
 
279
280
  Team state is observable via `agents teams list --json` / `agents teams status --json` (compact by default; add `--verbose` for the full per-teammate shape). External tools join it with `sessions --json` (teammates get `isTeamOrigin: true`) and `cloud list --json` (for `--cloud` teammates) to build a unified fleet view. See [docs/06-observability.md](docs/06-observability.md).
280
281
 
@@ -534,7 +535,7 @@ agents routines add daily-digest \
534
535
 
535
536
  agents routines list # All jobs + next run times
536
537
  agents routines run daily-digest # Test it now, ignore the schedule
537
- agents routines logs daily-digest # Check last execution
538
+ agents routines logs daily-digest # Last execution — status + report (add --full for raw stdout)
538
539
  ```
539
540
 
540
541
  Jobs run sandboxed -- agents only see directories and tools you explicitly allow.
@@ -218,12 +218,15 @@ export function registerRunCommand(program) {
218
218
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
219
219
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
220
220
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
221
+ .option('--name <slug>', 'Give the run a durable name — a stable handle you can check on later with `agents sessions <name>` (and `agents hosts logs <name>` for --host runs), instead of an opaque id. Optional; omitting it keeps today\'s id-only behavior.')
221
222
  .option('--verbose', 'Show detailed execution logs')
222
223
  .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
224
+ .option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing.')
225
+ .option('--disable-tmux', 'Alias for --no-tmux.')
223
226
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
224
227
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
225
228
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
226
- .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then pinned. (Legacy `rotate` accepted as alias for `balanced`.)')
229
+ .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then balanced (spreads load across healthy accounts and skips any that are rate-limited). (Legacy `rotate` accepted as alias for `balanced`.)')
227
230
  .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.')
228
231
  .option('-y, --yes', 'Skip the interactive budget-confirm prompt (require_confirm_over). Never skips a hard budget block.', false)
229
232
  .option('--loop', 'Re-inject the prompt/entrypoint each iteration until a stop condition (issue #332). Guards (--max-iterations, --budget, --until) are enforced outside the agent. Writes a checkpoint after every iteration for --resume-checkpoint.')
@@ -275,9 +278,10 @@ export function registerRunCommand(program) {
275
278
  Legacy 'full' is silently rewritten to 'skip'.
276
279
 
277
280
  Run strategy (set via --strategy or run.<agent>.strategy in agents.yaml):
278
- pinned use the workspace/global pinned version (default)
279
- available use pinned if usage available; otherwise switch to another signed-in version
280
- balanced distribute load across healthy accounts by remaining capacity
281
+ pinned use the workspace/global pinned version
282
+ available use pinned if it can run right now; otherwise switch to another signed-in version
283
+ balanced distribute load across healthy accounts by remaining capacity (default)
284
+ A version/account is skipped when it is rate-limited right now — any usage window (incl. the 5-hour session window) at 100%, matching the 'agents view' badge.
281
285
  --balanced is shorthand for --strategy balanced. Ignored when @version is pinned, when a profile is used, or with --fallback.
282
286
 
283
287
  Fallback: --fallback codex,gemini retries on rate-limit failure via /continue handoff. Each entry accepts @version.
@@ -404,15 +408,22 @@ export function registerRunCommand(program) {
404
408
  model: options.model,
405
409
  remoteCwd: options.remoteCwd,
406
410
  sessionId: hostSessionId,
411
+ name: options.name,
407
412
  resume: resumeId,
408
413
  follow: options.follow !== false,
409
414
  });
410
415
  // Register the dispatched run in the LOCAL session index so it shows
411
- // up in `agents sessions` and resolves by id, even though its
416
+ // up in `agents sessions` and resolves by id/name, even though its
412
417
  // transcript lives on the host. No-op when no session id was captured.
413
418
  registerHostSession(task, { cwd: process.cwd(), prompt });
414
419
  if (options.follow === false) {
415
- console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
420
+ // The handle the caller uses to check on the run: the name if given,
421
+ // else the real host-task id (never the old literal `<id>`). Steer
422
+ // to the compact `agents sessions` digest over the raw log first.
423
+ const handle = task.name ?? task.id;
424
+ console.log(chalk.green(`Dispatched to ${host.name}${task.name ? ` as "${task.name}"` : ''}.`) + '\n' +
425
+ chalk.gray(` Status: agents sessions ${handle}`) + chalk.gray(' (compact digest — use this)') + '\n' +
426
+ chalk.gray(` Raw log: agents hosts logs ${handle} -f`) + chalk.gray(' (heavy, only if needed)'));
416
427
  process.exit(0);
417
428
  }
418
429
  // -1 = the follow window closed but the run continues on the host (the
@@ -516,7 +527,7 @@ export function registerRunCommand(program) {
516
527
  });
517
528
  process.exit(resumeExit);
518
529
  }
519
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, 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 }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
530
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, 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([
520
531
  import('../lib/exec.js'),
521
532
  import('../lib/agents.js'),
522
533
  import('../lib/profiles.js'),
@@ -924,6 +935,29 @@ export function registerRunCommand(program) {
924
935
  }
925
936
  }
926
937
  }
938
+ // Self-heal the launch target. A gutted install (JS wrapper present,
939
+ // native binary missing — a partial/raced npm extraction of the optional
940
+ // per-arch dependency) would otherwise spawn and die with a raw ENOENT
941
+ // inside the agent's own wrapper. Repair it in place (or fall back to a
942
+ // runnable version, re-pinning it) BEFORE we build the launch command.
943
+ // Skipped for headless resume-native/acp/loop paths only if it errored;
944
+ // here it runs for every normal dispatch. Best-effort log to stderr.
945
+ {
946
+ const launchTarget = version ?? resolveVersion(agent, cwd) ?? undefined;
947
+ if (launchTarget) {
948
+ const healed = await ensureAgentRunnable(agent, launchTarget, options.quiet ? undefined : (m) => process.stderr.write(chalk.yellow(`[agents] ${m}\n`)));
949
+ if (healed === null) {
950
+ console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: agents add ${agent}@latest`));
951
+ process.exit(1);
952
+ }
953
+ // Always adopt the healed version explicitly. In the version-undefined
954
+ // path a fallback re-pins the GLOBAL default, but `resolveVersion`
955
+ // prefers a PROJECT pin — so leaving `version` undefined would let the
956
+ // shim re-resolve the still-broken project pin and crash anyway. Pinning
957
+ // the runnable version here is a no-op when nothing changed.
958
+ version = healed;
959
+ }
960
+ }
927
961
  const defaultVersion = version ?? resolveVersion(agent, cwd);
928
962
  const runDefaults = fromProfile
929
963
  ? { sources: {} }
@@ -1062,9 +1096,13 @@ export function registerRunCommand(program) {
1062
1096
  json: options.json,
1063
1097
  headless: options.headless,
1064
1098
  sessionId: resumeSessionId ?? options.sessionId,
1099
+ name: options.name,
1065
1100
  resume: resumeNative,
1066
1101
  verbose: options.verbose,
1067
- raw: options.raw,
1102
+ // --raw, --no-tmux (commander negation → options.tmux === false), and
1103
+ // --disable-tmux all bypass the interactive tmux wrapper. AGENTS_NO_TMUX=1
1104
+ // does the same via the env check in exec.ts.
1105
+ raw: options.raw || options.tmux === false || options.disableTmux === true,
1068
1106
  timeout: options.timeout,
1069
1107
  env,
1070
1108
  toolsRestrict: workflowToolsRestrict,
@@ -15,7 +15,7 @@ import { sshTargetFor } from '../lib/hosts/types.js';
15
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
17
17
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
- import { listTasks } from '../lib/hosts/tasks.js';
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
21
  /** Parse `user@host` or `host` into its pieces. */
@@ -179,18 +179,22 @@ async function doPs(json) {
179
179
  return;
180
180
  }
181
181
  const cols = terminalWidth();
182
- console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
182
+ console.log(chalk.bold('ID').padEnd(11) + chalk.bold('NAME').padEnd(16) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
183
183
  for (const t of tasks) {
184
184
  const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
185
+ const nameCol = truncateToWidth(t.name ?? chalk.gray('-'), 15).padEnd(16);
185
186
  // Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
186
- const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
187
- console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
187
+ const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 16 + 10 + 11)));
188
+ console.log(t.id.padEnd(11) + nameCol + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
188
189
  }
189
190
  }
190
- async function doLogs(id, follow) {
191
- const res = await showHostTaskLog(id, follow);
191
+ async function doLogs(ref, follow, full) {
192
+ // Resolve the ref as a task id first, then fall back to a `--name` handle so
193
+ // `agents hosts logs <name>` works, not just the opaque id.
194
+ const id = loadTask(ref) ? ref : (findTaskByName(ref)?.id ?? findTaskBySessionId(ref)?.id ?? ref);
195
+ const res = await showHostTaskLog(id, follow, full);
192
196
  if (!res.found) {
193
- console.log(chalk.red(`Unknown task "${id}".`));
197
+ console.log(chalk.red(`Unknown task "${ref}".`));
194
198
  process.exitCode = 1;
195
199
  return;
196
200
  }
@@ -231,7 +235,8 @@ export function registerHostsCommand(program) {
231
235
  .action((opts) => doPs(!!opts.json));
232
236
  hosts
233
237
  .command('logs <id>')
234
- .description('Show a host task log; -f to follow a running one.')
238
+ .description('Show a host task’s concise summary; --full for the raw log, -f to follow a running one.')
235
239
  .option('-f, --follow', 'Follow live output')
236
- .action((id, opts) => doLogs(id, !!opts.follow));
240
+ .option('-m, --full', 'Show the full raw combined-stdout log instead of the concise summary')
241
+ .action((id, opts) => doLogs(id, !!opts.follow, !!opts.full));
237
242
  }
@@ -5,6 +5,10 @@
5
5
  * - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
6
6
  * - sessions (the local index) → transcript, tailed via the sessions tailer
7
7
  *
8
+ * Concise by default: a bare `agents logs <id>` prints the same summary digest as
9
+ * `agents sessions <id>` — cheap for an agent to glance at. The token-heavy full
10
+ * transcript / raw stdout is opt-in behind `--full` (alias `-m/--markdown`).
11
+ *
8
12
  * `[id]`/`--session` load directly (host task tried first, then session). With no
9
13
  * id, `--host`/`--agent`/`--version` filter a merged candidate list; one match is
10
14
  * shown, several open the fuzzy picker (or, non-TTY, print the list).
@@ -5,6 +5,10 @@
5
5
  * - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
6
6
  * - sessions (the local index) → transcript, tailed via the sessions tailer
7
7
  *
8
+ * Concise by default: a bare `agents logs <id>` prints the same summary digest as
9
+ * `agents sessions <id>` — cheap for an agent to glance at. The token-heavy full
10
+ * transcript / raw stdout is opt-in behind `--full` (alias `-m/--markdown`).
11
+ *
8
12
  * `[id]`/`--session` load directly (host task tried first, then session). With no
9
13
  * id, `--host`/`--agent`/`--version` filter a merged candidate list; one match is
10
14
  * shown, several open the fuzzy picker (or, non-TTY, print the list).
@@ -33,8 +37,8 @@ function candidateLabel(c) {
33
37
  const title = s.label || s.topic || '';
34
38
  return `${chalk.gray('sess')} ${s.shortId.padEnd(9)} ${(s.agent + ver).padEnd(14)} ${chalk.gray(s.timestamp.slice(0, 16))} ${title.slice(0, 40)}`;
35
39
  }
36
- /** Show a resolved session — follow (tail) or render its transcript. */
37
- async function showSession(session, follow) {
40
+ /** Show a resolved session — follow (tail), concise summary, or (`full`) transcript. */
41
+ async function showSession(session, follow, full) {
38
42
  if (follow) {
39
43
  if (!isTailable(session.agent)) {
40
44
  console.error(chalk.red(`Tailing is supported for claude and codex sessions only (got ${session.agent}).`));
@@ -43,20 +47,20 @@ async function showSession(session, follow) {
43
47
  await streamSessionTail(session, {});
44
48
  return;
45
49
  }
46
- await renderSessionLog(session);
50
+ await renderSessionLog(session, full ? 'markdown' : 'summary');
47
51
  }
48
- async function showCandidate(c, follow) {
52
+ async function showCandidate(c, follow, full) {
49
53
  if (c.kind === 'task') {
50
- const res = await showHostTaskLog(c.task.id, follow);
54
+ const res = await showHostTaskLog(c.task.id, follow, full);
51
55
  if (res.exitCode !== undefined)
52
56
  process.exitCode = res.exitCode;
53
57
  return;
54
58
  }
55
- await showSession(c.session, follow);
59
+ await showSession(c.session, follow, full);
56
60
  }
57
61
  /** Resolve an explicit id/--session: host task first, then a session. */
58
- async function showById(id, follow) {
59
- const hostRes = await showHostTaskLog(id, follow);
62
+ async function showById(id, follow, full) {
63
+ const hostRes = await showHostTaskLog(id, follow, full);
60
64
  if (hostRes.found) {
61
65
  if (hostRes.exitCode !== undefined)
62
66
  process.exitCode = hostRes.exitCode;
@@ -68,13 +72,14 @@ async function showById(id, follow) {
68
72
  console.error(chalk.red(`No run or session found matching "${id}".`));
69
73
  process.exit(1);
70
74
  }
71
- await showSession(matches[0], follow);
75
+ await showSession(matches[0], follow, full);
72
76
  }
73
77
  async function runLogs(id, opts) {
74
78
  const follow = !!opts.follow;
79
+ const full = !!opts.full;
75
80
  const directId = opts.session ?? id;
76
81
  if (directId) {
77
- await showById(directId, follow);
82
+ await showById(directId, follow, full);
78
83
  return;
79
84
  }
80
85
  const { agent, version } = parseAgentFilter(opts.agent);
@@ -99,7 +104,7 @@ async function runLogs(id, opts) {
99
104
  process.exit(1);
100
105
  }
101
106
  if (candidates.length === 1) {
102
- await showCandidate(candidates[0], follow);
107
+ await showCandidate(candidates[0], follow, full);
103
108
  return;
104
109
  }
105
110
  // Multiple sessions matched → picker if interactive, else a list to pick from.
@@ -123,17 +128,18 @@ async function runLogs(id, opts) {
123
128
  });
124
129
  if (!picked)
125
130
  return;
126
- await showCandidate(picked.item, follow);
131
+ await showCandidate(picked.item, follow, full);
127
132
  }
128
133
  /** Register the top-level `agents logs` command. */
129
134
  export function registerLogsCommand(program) {
130
135
  program
131
136
  .command('logs [id]')
132
- .description('Show a run’s log — a host-dispatch task or a session. -f to follow a live one.')
137
+ .description('Show a run’s concise summary — a host-dispatch task or a session. --full for the raw transcript, -f to follow a live one.')
133
138
  .option('--host <name>', 'Scope to runs dispatched to a host')
134
139
  .option('-a, --agent <agent>', 'Filter by agent (e.g. claude, codex@0.116.0)')
135
140
  .option('--version <version>', 'Filter by agent version')
136
141
  .option('--session <id>', 'Select a session/run by id (same as the positional id)')
137
142
  .option('-f, --follow', 'Follow live output')
143
+ .option('-m, --full', 'Show the full raw transcript / stdout instead of the concise summary')
138
144
  .action((id, opts) => runLogs(id, opts));
139
145
  }
@@ -6,5 +6,11 @@
6
6
  * Also exposes scheduler lifecycle controls (start/stop/status/logs).
7
7
  */
8
8
  import type { Command } from 'commander';
9
+ /**
10
+ * Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
11
+ * when it hasn't completed or timestamps are unparseable. Leading separator lets
12
+ * callers drop it straight into a status line.
13
+ */
14
+ export declare function formatRunDuration(startedAt: string, completedAt: string | null): string;
9
15
  /** Register the `agents routines` command tree. */
10
16
  export declare function registerRoutinesCommands(program: Command): void;
@@ -22,6 +22,27 @@ import { JobScheduler } from '../lib/scheduler.js';
22
22
  import { detectOverdueJobs } from '../lib/overdue.js';
23
23
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
24
24
  import { setHelpSections } from '../lib/help.js';
25
+ /**
26
+ * Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
27
+ * when it hasn't completed or timestamps are unparseable. Leading separator lets
28
+ * callers drop it straight into a status line.
29
+ */
30
+ export function formatRunDuration(startedAt, completedAt) {
31
+ if (!completedAt)
32
+ return '';
33
+ const ms = Date.parse(completedAt) - Date.parse(startedAt);
34
+ if (!Number.isFinite(ms) || ms < 0)
35
+ return '';
36
+ const sec = Math.round(ms / 1000);
37
+ if (sec < 60)
38
+ return ` · ${sec} sec`;
39
+ const min = Math.round(sec / 60);
40
+ if (min < 60)
41
+ return ` · ${min} min`;
42
+ const hr = Math.floor(min / 60);
43
+ const rem = min % 60;
44
+ return rem ? ` · ${hr} hr ${rem} min` : ` · ${hr} hr`;
45
+ }
25
46
  /**
26
47
  * Human label for what fires a job: its cron schedule, or its event trigger
27
48
  * for schedule-less (trigger-only) routines.
@@ -660,30 +681,67 @@ export function registerRoutinesCommands(program) {
660
681
  });
661
682
  routinesCmd
662
683
  .command('logs [name]')
663
- .description('Read stdout from the most recent execution. Use --run to see a specific past run.')
684
+ .description('Show a run’s concise summary status + extracted report. --full for the raw stdout stream; --run for a specific past run.')
664
685
  .option('-r, --run <runId>', 'Show logs from this run ID instead of the latest')
686
+ .option('-m, --full', 'Show the full raw stdout stream instead of the concise summary')
665
687
  .action(async (name, options) => {
666
688
  if (!name) {
667
689
  name = await pickJob('Select job to view logs', undefined, ['agents routines logs <name>', 'agents routines logs <name> --run <run-id>']) ?? undefined;
668
690
  if (!name)
669
691
  return;
670
692
  }
671
- let runId = options.run;
672
- if (!runId) {
673
- const latest = getLatestRun(name);
674
- if (!latest) {
675
- console.log(chalk.yellow(`No runs found for job '${name}'`));
693
+ // Resolve the run: an explicit --run row, else the latest.
694
+ const run = options.run
695
+ ? listRuns(name).find((r) => r.runId === options.run)
696
+ : getLatestRun(name);
697
+ if (!run) {
698
+ console.log(chalk.yellow(options.run ? `No run '${options.run}' for job '${name}'` : `No runs found for job '${name}'`));
699
+ return;
700
+ }
701
+ const runId = run.runId;
702
+ const logPath = path.join(getRunDir(name, runId), 'stdout.log');
703
+ // --full: the raw combined stdout stream (the old default).
704
+ if (options.full) {
705
+ if (!fs.existsSync(logPath)) {
706
+ console.log(chalk.yellow(`Log not found: ${logPath}`));
676
707
  return;
677
708
  }
678
- runId = latest.runId;
709
+ console.log(chalk.gray(`Run: ${runId}\n`));
710
+ console.log(fs.readFileSync(logPath, 'utf-8'));
711
+ return;
679
712
  }
680
- const logPath = path.join(getRunDir(name, runId), 'stdout.log');
681
- if (!fs.existsSync(logPath)) {
682
- console.log(chalk.yellow(`Log not found: ${logPath}`));
713
+ // Concise by default: a status header + the extracted report (final
714
+ // assistant message). Routine runs are sandboxed (transcript in an overlay
715
+ // HOME, not the session index), so the captured report — not renderSummary —
716
+ // is the concise view. Falls back to a bounded stdout tail when no report
717
+ // was extracted (e.g. the run failed before finishing).
718
+ const statusColor = run.status === 'completed' ? chalk.green
719
+ : run.status === 'failed' || run.status === 'timeout' ? chalk.red
720
+ : chalk.yellow;
721
+ console.log(chalk.bold(name) + chalk.gray(` run ${runId}`));
722
+ console.log(statusColor(run.status) +
723
+ chalk.gray(` ${run.startedAt}`) +
724
+ chalk.gray(formatRunDuration(run.startedAt, run.completedAt)) +
725
+ (run.exitCode !== null && run.exitCode !== undefined ? chalk.gray(` exit ${run.exitCode}`) : ''));
726
+ console.log(chalk.gray('─'.repeat(60)));
727
+ const reportPath = path.join(getRunDir(name, runId), 'report.md');
728
+ if (fs.existsSync(reportPath)) {
729
+ console.log(fs.readFileSync(reportPath, 'utf-8').trimEnd());
730
+ console.log(chalk.gray('\n(pass --full for the raw stdout stream)'));
683
731
  return;
684
732
  }
685
- console.log(chalk.gray(`Run: ${runId}\n`));
686
- console.log(fs.readFileSync(logPath, 'utf-8'));
733
+ // No report — show a bounded tail rather than dumping the whole stream.
734
+ if (fs.existsSync(logPath)) {
735
+ const lines = fs.readFileSync(logPath, 'utf-8').split('\n');
736
+ const tail = lines.slice(-40).join('\n').trimEnd();
737
+ console.log(chalk.gray('(no report extracted — showing the last lines of stdout)'));
738
+ if (tail)
739
+ console.log(tail);
740
+ console.log(chalk.gray('\n(pass --full for the raw stdout stream)'));
741
+ }
742
+ else {
743
+ console.log(chalk.gray('(no output captured for this run)'));
744
+ }
687
745
  });
688
746
  routinesCmd
689
747
  .command('report [name]')
@@ -1,5 +1,5 @@
1
1
  import type { Command } from 'commander';
2
- import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
2
+ import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { type PickedSession } from './sessions-picker.js';
5
5
  /**
@@ -149,11 +149,12 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
149
149
  projectCount: number;
150
150
  };
151
151
  /**
152
- * Render a session's full transcript to stdout — the non-follow view behind
153
- * `agents logs <sessionId>`. Reuses the same markdown renderer as
154
- * `agents sessions <id> --markdown`.
152
+ * Render a resolved session to stdout — the non-follow view behind
153
+ * `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
154
+ * `agents sessions <id>`); pass `'markdown'` for the full transcript
155
+ * (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
155
156
  */
156
- export declare function renderSessionLog(session: SessionMeta): Promise<void>;
157
+ export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode): Promise<void>;
157
158
  /** Column-visibility flags for the picker row, computed once over the whole pool. */
158
159
  export interface PickerColumns {
159
160
  /** Render the machine column (only when the pool spans more than one machine). */