@phnx-labs/agents-cli 1.22.33 → 1.22.34

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 (73) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/ssh.js +17 -5
  15. package/dist/commands/teams.d.ts +28 -0
  16. package/dist/commands/teams.js +148 -13
  17. package/dist/commands/upgrade.d.ts +7 -0
  18. package/dist/commands/upgrade.js +10 -0
  19. package/dist/commands/watchdog.d.ts +2 -0
  20. package/dist/commands/watchdog.js +112 -27
  21. package/dist/index.js +51 -59
  22. package/dist/lib/agents.js +6 -0
  23. package/dist/lib/browser/sessions-list.d.ts +81 -0
  24. package/dist/lib/browser/sessions-list.js +179 -4
  25. package/dist/lib/daemon.js +45 -5
  26. package/dist/lib/devices/connect.d.ts +33 -0
  27. package/dist/lib/devices/connect.js +61 -3
  28. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  29. package/dist/lib/devices/doctor-findings.js +4 -2
  30. package/dist/lib/exec.js +63 -6
  31. package/dist/lib/help.d.ts +3 -2
  32. package/dist/lib/help.js +4 -0
  33. package/dist/lib/hosts/dispatch.d.ts +2 -32
  34. package/dist/lib/hosts/dispatch.js +6 -61
  35. package/dist/lib/hosts/tasks.d.ts +7 -0
  36. package/dist/lib/hosts/tasks.js +9 -0
  37. package/dist/lib/mailbox-target.d.ts +27 -0
  38. package/dist/lib/mailbox-target.js +21 -0
  39. package/dist/lib/mcp.d.ts +10 -0
  40. package/dist/lib/mcp.js +21 -2
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/migrate.d.ts +11 -0
  44. package/dist/lib/migrate.js +40 -0
  45. package/dist/lib/project-root.d.ts +47 -0
  46. package/dist/lib/project-root.js +68 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  48. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  49. package/dist/lib/secrets/agent.d.ts +9 -2
  50. package/dist/lib/secrets/agent.js +52 -7
  51. package/dist/lib/secrets/reaper.d.ts +24 -3
  52. package/dist/lib/secrets/reaper.js +55 -6
  53. package/dist/lib/session/discover.js +12 -0
  54. package/dist/lib/session/render.d.ts +2 -0
  55. package/dist/lib/session/render.js +1 -1
  56. package/dist/lib/shims.js +9 -2
  57. package/dist/lib/startup/command-registry.d.ts +1 -0
  58. package/dist/lib/startup/command-registry.js +8 -3
  59. package/dist/lib/startup/root-command.d.ts +3 -0
  60. package/dist/lib/startup/root-command.js +10 -0
  61. package/dist/lib/teams/agents.d.ts +136 -6
  62. package/dist/lib/teams/agents.js +324 -58
  63. package/dist/lib/teams/worktree.d.ts +39 -2
  64. package/dist/lib/teams/worktree.js +60 -4
  65. package/dist/lib/types.d.ts +11 -0
  66. package/dist/lib/versions.js +2 -2
  67. package/dist/lib/watchdog/history.d.ts +20 -0
  68. package/dist/lib/watchdog/history.js +46 -0
  69. package/dist/lib/watchdog/log.d.ts +16 -1
  70. package/dist/lib/watchdog/log.js +82 -2
  71. package/dist/lib/watchdog/runner.d.ts +12 -0
  72. package/dist/lib/watchdog/runner.js +20 -0
  73. package/package.json +1 -1
@@ -15,7 +15,7 @@ import { runPrWatch, DEFAULT_MAX_WAVES, } from '../lib/teams/pr-watch.js';
15
15
  import { handleSpawn, handleStatus, handleStop, toTaskStatusSummary, } from '../lib/teams/api.js';
16
16
  import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
17
17
  import { setHelpSections } from '../lib/help.js';
18
- import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
18
+ import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, worktreeCheckoutExists, worktreeExists, } from '../lib/teams/worktree.js';
19
19
  import { resolveHost } from '../lib/hosts/registry.js';
20
20
  import { isDeviceAuto, resolveDeviceAuto } from '../lib/smart-launch.js';
21
21
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -251,6 +251,54 @@ function factoryWorkerPreamble(team, name, taskType, after) {
251
251
  function mkManager() {
252
252
  return new AgentManager();
253
253
  }
254
+ /**
255
+ * Tell the user a worktree from a failed `teams add` is still on disk, and give
256
+ * them the exact commands to remove it. Reached only when we could not remove it
257
+ * ourselves — a leftover `agents/<name>` branch is what makes the retry fail with
258
+ * `fatal: a branch ... already exists`, so it must never be silent (RUSH-2356).
259
+ */
260
+ /**
261
+ * Tear down a worktree a failed operation left behind — but ONLY if it is a
262
+ * genuine orphan.
263
+ *
264
+ * Extracted because this guard was hand-duplicated at two call sites (`teams
265
+ * add` and the pr-watch fixer) and the copy at the fixer path was written
266
+ * WITHOUT it, which is RUSH-2356 reoccurring at the one site that skipped the
267
+ * check. One implementation, one place to get it right.
268
+ *
269
+ * The two errors here are not symmetric, so the guard deliberately fails
270
+ * CLOSED: a spurious "claimed" strands a branch a human can delete, while a
271
+ * spurious "unclaimed" runs `git worktree remove --force` over a live
272
+ * teammate's checkout and destroys uncommitted work. So an unreadable record
273
+ * means "leave it alone and tell the user", never "assume it is free".
274
+ *
275
+ * A staged teammate is exactly the case that makes this necessary: `spawn()`
276
+ * persists its meta.json and only THEN runs the retention pass, which refreshes
277
+ * every sibling and can throw on a distributed one — so a failure can land here
278
+ * with a live, durably-recorded, merely-pending `--after` teammate already
279
+ * owning this worktree.
280
+ */
281
+ export async function tearDownOrphanWorktree(mgr, baseCwd, name) {
282
+ try {
283
+ if (await mgr.isWorktreeClaimed(name))
284
+ return;
285
+ }
286
+ catch {
287
+ warnOrphanWorktree(baseCwd, name, 'the teammate record could not be read');
288
+ return;
289
+ }
290
+ try {
291
+ await removeWorktree(baseCwd, name);
292
+ }
293
+ catch (cleanupErr) {
294
+ warnOrphanWorktree(baseCwd, name, cleanupErr.message);
295
+ }
296
+ }
297
+ function warnOrphanWorktree(baseCwd, name, reason) {
298
+ process.stderr.write(chalk.yellow(`\nWarning: the worktree '${name}' from this failed add is still on disk — ${reason}.\n` +
299
+ ` Remove it manually: git -C ${baseCwd} worktree remove --force .agents/worktrees/${name} ` +
300
+ `&& git -C ${baseCwd} branch -D agents/${name}\n`));
301
+ }
254
302
  /**
255
303
  * Register the generic cloud dispatcher — staged cloud teammates get
256
304
  * dispatched when their --after deps resolve, using repo/branch stored on
@@ -476,7 +524,27 @@ async function reactWithTeammate(mgr, team, action, prompt) {
476
524
  cwd = baseCwd;
477
525
  }
478
526
  }
479
- const result = await handleSpawn(mgr, team, 'claude', prompt, cwd, 'edit', 'medium', null, cwd, null, name, after, null, null, taskType, null, null, null, null, worktreeName, worktreePath);
527
+ let result;
528
+ try {
529
+ result = await handleSpawn(mgr, team, 'claude', prompt, cwd, 'edit', 'medium', null, cwd, null, name, after, null, null, taskType, null, null, null, null, worktreeName, worktreePath);
530
+ }
531
+ catch (err) {
532
+ // The spawn failed after we created this fixer's worktree — tear it down so
533
+ // the branch `agents/<name>` doesn't block the next wave's retry with
534
+ // `fatal: a branch ... already exists` (RUSH-2356). Best-effort; the
535
+ // original failure is what propagates.
536
+ //
537
+ // Guarded exactly like the `teams add` teardown, and for the same reason:
538
+ // handleSpawn -> manager.spawn() persists a staged teammate's meta.json and
539
+ // only THEN runs the retention pass, which refreshes every sibling and can
540
+ // throw on a distributed one. A fixer teammate is staged with `--after` when
541
+ // it follows a source teammate, so a throw there lands here with a LIVE,
542
+ // durably-recorded, merely-pending teammate already owning this worktree —
543
+ // and removing it would destroy real work. Only tear down a genuine orphan.
544
+ if (worktreeName)
545
+ await tearDownOrphanWorktree(mgr, baseCwd, worktreeName);
546
+ throw err;
547
+ }
480
548
  return result.name ?? shortId(result.agent_id);
481
549
  }
482
550
  // Pick the display handle for a teammate: explicit teammate name, Claude
@@ -1489,6 +1557,31 @@ export function registerTeamsCommands(program) {
1489
1557
  const sharedWorktree = teamMeta?.use_worktree ?? null;
1490
1558
  let worktreeName = null;
1491
1559
  let worktreePath = null;
1560
+ const mgr = mkManager();
1561
+ // Validate name uniqueness + --after deps BEFORE creating a worktree
1562
+ // (RUSH-2356): a rejected add must not leave an orphan `agents/<name>`
1563
+ // branch that then breaks the retry with `fatal: a branch ... already
1564
+ // exists`. spawn() calls the same method, which memoizes this result for
1565
+ // exactly one consumer — so validation still lives in one place without
1566
+ // paying for a second sibling status refresh on the way there.
1567
+ try {
1568
+ await mgr.validateAddPreconditions(team, opts.name ?? null, after);
1569
+ }
1570
+ catch (err) {
1571
+ dieFriction('teams', 'add-precondition-failed', err.message);
1572
+ }
1573
+ // Track a worktree WE create in this add so a later failure (dep race,
1574
+ // launch error, cloud dispatch) can tear it down instead of stranding the
1575
+ // branch. Only a worktree THIS add created is ever torn down, so a
1576
+ // legitimately-pending teammate's worktree is never touched.
1577
+ let createdWorktree = null;
1578
+ const tearDownCreatedWorktree = async () => {
1579
+ if (!createdWorktree)
1580
+ return;
1581
+ const { baseCwd, name } = createdWorktree;
1582
+ createdWorktree = null;
1583
+ await tearDownOrphanWorktree(mgr, baseCwd, name);
1584
+ };
1492
1585
  if (hostName) {
1493
1586
  // Distributed teammate: the checkout lives on the host, so we NEVER touch
1494
1587
  // the local filesystem here. A shared local worktree makes no sense for a
@@ -1539,12 +1632,43 @@ export function registerTeamsCommands(program) {
1539
1632
  if (!(await isGitRepo(baseCwd))) {
1540
1633
  dieFriction('teams', 'worktree-needs-repo', `Worktrees require a git repository. ${baseCwd} is not inside a git repo.`);
1541
1634
  }
1635
+ // Was anything under this name already here BEFORE we tried? That is
1636
+ // the only sound basis for cleaning up a failed create: the common
1637
+ // failure is `fatal: a branch named 'agents/<name>' already exists`,
1638
+ // and removing a checkout we did not make can destroy uncommitted work
1639
+ // (`teams stop` deliberately KEEPS a dirty worktree, and that teammate's
1640
+ // record is terminal by then, so no record-based check protects it).
1641
+ const preexisting = await worktreeExists(baseCwd, opts.worktree).catch(() => true);
1542
1642
  try {
1543
1643
  worktreeName = opts.worktree;
1544
1644
  worktreePath = await createWorktree(baseCwd, worktreeName);
1645
+ createdWorktree = { baseCwd, name: worktreeName };
1545
1646
  }
1546
1647
  catch (err) {
1547
- dieFriction('teams', 'worktree-create-failed', `Failed to create worktree '${opts.worktree}': ${err.message}`);
1648
+ // `git worktree add -b` can also fail PART WAY — branch ref created,
1649
+ // checkout not — stranding `agents/<name>` in exactly the way that
1650
+ // breaks the retry. Clean that up only when nothing was here before
1651
+ // AND there is still no checkout now: all that can be left is a
1652
+ // dangling branch ref, so this can never delete anyone's files. The
1653
+ // second half matters because a concurrent add can create a real
1654
+ // worktree under this name during our `git fetch`, long after the
1655
+ // pre-flight probe answered.
1656
+ const checkoutNow = await worktreeCheckoutExists(baseCwd, opts.worktree).catch(() => true);
1657
+ if (!preexisting && !checkoutNow) {
1658
+ try {
1659
+ await removeWorktree(baseCwd, opts.worktree);
1660
+ }
1661
+ catch {
1662
+ // Nothing to remove (the add died before git wrote anything), or
1663
+ // we can't. Either way the real error below is what matters.
1664
+ }
1665
+ }
1666
+ const detail = err.message;
1667
+ const hint = /already exists/.test(detail)
1668
+ ? `\n Another teammate already owns the worktree '${opts.worktree}'. Pick a different --worktree name,` +
1669
+ ` or free this one: agents teams status ${team}`
1670
+ : '';
1671
+ dieFriction('teams', 'worktree-create-failed', `Failed to create worktree '${opts.worktree}': ${detail}${hint}`);
1548
1672
  }
1549
1673
  }
1550
1674
  else if (opts.worktree) {
@@ -1554,7 +1678,6 @@ export function registerTeamsCommands(program) {
1554
1678
  // host (repoPath / the remote worktree). Local teammates default to the
1555
1679
  // worktree path, then --cwd, then the current directory.
1556
1680
  const cwd = hostName ? null : (worktreePath ?? opts.cwd ?? process.cwd());
1557
- const mgr = mkManager();
1558
1681
  // Factory teammates: prepend the worker-skill preamble to every task
1559
1682
  // prompt so implementers/testers/reviewers know about the Ledger, the
1560
1683
  // dynamic DAG, and the pattern for filing new tasks mid-flight. No
@@ -1586,20 +1709,28 @@ export function registerTeamsCommands(program) {
1586
1709
  if (cloudProviderId && !isStaged) {
1587
1710
  // Ready to run now: dispatch to the cloud provider before registering
1588
1711
  // the teammate so we have the remote session id up front.
1589
- const prov = resolveProvider(cloudProviderId);
1590
- const dispatchOpts = {
1591
- prompt: effectiveTask,
1592
- agent,
1593
- repo: opts.repo,
1594
- branch: opts.branch,
1595
- model: opts.model,
1596
- env: shareRuntimeEnv(),
1597
- };
1712
+ //
1713
+ // resolveProvider() and shareRuntimeEnv() are INSIDE the try on purpose:
1714
+ // both read config (the cloud registry; `agents.yaml`'s share block) and
1715
+ // both throw on a malformed one, and by this point the worktree already
1716
+ // exists — leaving them outside stranded the branch with no catch at all.
1598
1717
  try {
1718
+ const prov = resolveProvider(cloudProviderId);
1719
+ const dispatchOpts = {
1720
+ prompt: effectiveTask,
1721
+ agent,
1722
+ repo: opts.repo,
1723
+ branch: opts.branch,
1724
+ model: opts.model,
1725
+ env: shareRuntimeEnv(),
1726
+ };
1599
1727
  const cloudTask = await prov.dispatch(dispatchOpts);
1600
1728
  cloudSessionId = cloudTask.id;
1601
1729
  }
1602
1730
  catch (err) {
1731
+ // Same orphan class as a failed spawn: this add already created its
1732
+ // worktree, so a dispatch failure must not strand the branch either.
1733
+ await tearDownCreatedWorktree();
1603
1734
  dieFriction('teams', 'cloud-dispatch-failed', `Cloud dispatch failed: ${err.message}`);
1604
1735
  }
1605
1736
  }
@@ -1654,6 +1785,10 @@ export function registerTeamsCommands(program) {
1654
1785
  }
1655
1786
  }
1656
1787
  catch (err) {
1788
+ // The add failed after we created its worktree — tear the worktree +
1789
+ // branch down so a retry isn't blocked by `fatal: a branch ... already
1790
+ // exists` (RUSH-2356). Best-effort: report the original failure either way.
1791
+ await tearDownCreatedWorktree();
1657
1792
  dieFriction('teams', 'add-failed', `Could not add ${fullName(agent, version)} to ${team}: ${err.message}`);
1658
1793
  }
1659
1794
  });
@@ -0,0 +1,7 @@
1
+ import type { Command } from 'commander';
2
+ export interface UpgradeOptions {
3
+ yes?: boolean;
4
+ }
5
+ export type UpgradeAction = (version: string | undefined, options: UpgradeOptions) => Promise<void>;
6
+ /** Register the public self-upgrade surface; the entry point supplies its runtime action. */
7
+ export declare function registerUpgradeCommand(program: Command, action?: UpgradeAction): Command;
@@ -0,0 +1,10 @@
1
+ /** Register the public self-upgrade surface; the entry point supplies its runtime action. */
2
+ export function registerUpgradeCommand(program, action) {
3
+ const command = program.command('upgrade')
4
+ .description('Upgrade agents-cli to the latest version (or a specific [version])')
5
+ .argument('[version]', 'Target version or dist-tag to install (default: latest)')
6
+ .option('-y, --yes', 'Install without an interactive confirmation prompt');
7
+ if (action)
8
+ command.action(action);
9
+ return command;
10
+ }
@@ -17,5 +17,7 @@
17
17
  * persisted state; it never runs a tick.
18
18
  */
19
19
  import type { Command } from 'commander';
20
+ import { type WatchdogTickResult } from '../lib/watchdog/runner.js';
21
+ export declare function formatWatchdogTickLines(result: WatchdogTickResult, willInject: boolean, verbose?: boolean): string[];
20
22
  /** Register the `agents watchdog` command tree. */
21
23
  export declare function registerWatchdogCommand(program: Command): void;
@@ -25,6 +25,8 @@ import { getConfigValue, setConfigValue } from '../lib/device-config.js';
25
25
  import { writePolicySentinel, DEFAULT_THRESHOLDS, } from '../lib/watchdog/runner.js';
26
26
  import { isWatchdogRotateEnabled, listRotateStates, setWatchdogRotateEnabled } from '../lib/watchdog/rotate.js';
27
27
  import { loadWatchdogSessions, runWatchdogPass } from '../lib/watchdog/service.js';
28
+ import { readWatchdogEvents, WATCHDOG_LOG_PATH } from '../lib/watchdog/log.js';
29
+ import { selectWatchdogHistory } from '../lib/watchdog/history.js';
28
30
  /** Default state dir the runner and these subcommands share. */
29
31
  function stateDir() {
30
32
  return path.join(getRuntimeStateDir(), 'watchdog');
@@ -57,39 +59,75 @@ function humanMs(ms) {
57
59
  return `${Math.round(ms / 60_000)}m`;
58
60
  return `${Math.round(ms / 1000)}s`;
59
61
  }
60
- function colorForOutcome(o) {
61
- if (o.injected)
62
- return chalk.green;
63
- if (o.decision === 'rotate')
64
- return chalk.magenta;
65
- if (o.addressable === false)
66
- return chalk.yellow;
67
- if (o.stall === 'stalled' && o.decision === 'nudge')
68
- return chalk.cyan;
69
- return chalk.dim;
70
- }
71
62
  /** Render one tick's outcomes as a human status block. */
72
- function printTick(result, willInject) {
63
+ function elapsedLabel(atMs, eventMs) {
64
+ const seconds = Math.max(0, Math.round((atMs - eventMs) / 1000));
65
+ if (seconds < 60)
66
+ return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
67
+ const minutes = Math.round(seconds / 60);
68
+ if (minutes < 60)
69
+ return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
70
+ const hours = Math.round(minutes / 60);
71
+ if (hours < 48)
72
+ return `${hours} hour${hours === 1 ? '' : 's'} ago`;
73
+ const days = Math.round(hours / 24);
74
+ return `${days} day${days === 1 ? '' : 's'} ago`;
75
+ }
76
+ function isAttentionOutcome(outcome) {
77
+ return !outcome.sessionId
78
+ || outcome.lastActivityMs === undefined
79
+ || outcome.stall === 'stalled'
80
+ || outcome.decision !== 'skip'
81
+ || outcome.injected === true
82
+ || outcome.addressable === false
83
+ || outcome.rotatePhase === 'failed';
84
+ }
85
+ export function formatWatchdogTickLines(result, willInject, verbose = false) {
73
86
  const { counts } = result;
74
- const mode = willInject ? chalk.green('nudge') : chalk.dim('dry');
75
- console.log(`${chalk.bold('watchdog')} ${mode} ` +
76
- `${counts.total} live · ${counts.stalled} stalled · ` +
77
- `${chalk.green(String(counts.nudged))} nudged · ` +
78
- `${chalk.yellow(String(counts.unaddressable))} un-addressable` +
79
- (counts.rotating > 0 ? ` · ${chalk.magenta(String(counts.rotating))} rotating` : ''));
80
- for (const o of result.outcomes) {
87
+ const mode = willInject ? 'nudge' : 'dry';
88
+ const lines = [
89
+ `watchdog ${mode} · checked ${new Date(result.atMs).toLocaleString()} · ` +
90
+ `${counts.total} live · ${counts.stalled} stalled · ` +
91
+ `${counts.nudged} nudged · ${counts.unaddressable} un-addressable` +
92
+ (counts.rotating > 0 ? ` · ${counts.rotating} rotating` : ''),
93
+ ];
94
+ const visible = verbose ? result.outcomes : result.outcomes.filter(isAttentionOutcome);
95
+ for (const o of visible) {
81
96
  const tag = o.injected ? 'NUDGED'
82
97
  : o.decision === 'rotate' ? (o.rotatePhase === 'failed' ? 'ROTATE-FAIL' : 'ROTATE')
83
98
  : o.addressable === false ? 'FLAGGED'
84
99
  : o.decision === 'nudge' ? 'WOULD-NUDGE'
85
100
  : 'skip';
86
- const c = colorForOutcome(o);
87
- const who = o.label || o.sessionId?.slice(0, 8) || o.kind;
88
- const where = o.host ? chalk.dim(`[${o.host}]`) : '';
89
- const rail = o.rail ? chalk.dim(`→${o.rail}`) : '';
90
- console.log(` ${c(tag.padEnd(11))} ${chalk.bold(who)} ${where} ${rail}`);
91
- console.log(` ${chalk.dim(o.reason)}`);
101
+ const id = o.sessionId?.slice(0, 8) ?? 'no-session-id';
102
+ const title = o.label || o.name || o.topic;
103
+ lines.push(` ${tag.padEnd(11)} ${id}${title ? ` · ${title}` : ''}`);
104
+ const metadata = [
105
+ o.kind,
106
+ o.host,
107
+ o.machine ?? 'local',
108
+ o.project ?? (o.cwd ? path.basename(o.cwd) : undefined),
109
+ o.activity ?? o.status,
110
+ o.origin === 'routine' ? `routine ${o.routineName ?? 'unknown'}` : undefined,
111
+ o.owner ? `owner ${o.owner}` : undefined,
112
+ o.startedAtMs ? `started ${elapsedLabel(result.atMs, o.startedAtMs)}` : undefined,
113
+ o.lastActivityMs ? `activity ${elapsedLabel(result.atMs, o.lastActivityMs)}` : undefined,
114
+ o.rail ? `rail ${o.rail}` : undefined,
115
+ ].filter((value) => Boolean(value));
116
+ lines.push(` ${metadata.join(' · ')}`);
117
+ if (o.cwd)
118
+ lines.push(` cwd ${o.cwd}`);
119
+ if (o.preview)
120
+ lines.push(` latest ${o.preview.replace(/\s+/g, ' ').slice(0, 140)}`);
121
+ lines.push(` reason ${o.reason}`);
92
122
  }
123
+ const omitted = result.outcomes.length - visible.length;
124
+ if (omitted > 0)
125
+ lines.push(` ${omitted} healthy/non-actionable session${omitted === 1 ? '' : 's'} omitted · use --verbose or --json to inspect all`);
126
+ return lines;
127
+ }
128
+ function printTick(result, willInject, verbose) {
129
+ for (const line of formatWatchdogTickLines(result, willInject, verbose))
130
+ console.log(line);
93
131
  }
94
132
  async function sleep(ms) {
95
133
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -109,6 +147,7 @@ export function registerWatchdogCommand(program) {
109
147
  .option('--smart', 'Use the LLM decider (agents run) instead of the deterministic path (non-reproducible)')
110
148
  .option('--smart-agent <agent>', 'Agent the --smart decider runs as', 'claude')
111
149
  .option('--allow-ghostty-focus', 'Permit the coarse, focus-stealing Ghostty path (off by default)')
150
+ .option('--verbose', 'Show healthy and non-actionable session inspections too')
112
151
  .option('--json', 'Emit the tick result as JSON (for the menu-bar / scripts)')
113
152
  .action(async (opts) => {
114
153
  const thresholds = {
@@ -139,7 +178,7 @@ export function registerWatchdogCommand(program) {
139
178
  if (opts.json)
140
179
  console.log(JSON.stringify(result, null, 2));
141
180
  else
142
- printTick(result, willInject);
181
+ printTick(result, willInject, opts.verbose === true);
143
182
  return;
144
183
  }
145
184
  // Manual poll loop for ad-hoc use; the daemon owns the automatic cadence.
@@ -157,7 +196,7 @@ export function registerWatchdogCommand(program) {
157
196
  if (opts.json)
158
197
  console.log(JSON.stringify(result));
159
198
  else
160
- printTick(result, willInject);
199
+ printTick(result, willInject, opts.verbose === true);
161
200
  await sleep(intervalMs);
162
201
  }
163
202
  });
@@ -181,6 +220,12 @@ export function registerWatchdogCommand(program) {
181
220
  # Show device enablement, rotate config, and in-flight rotates
182
221
  agents watchdog status
183
222
 
223
+ # Show what Watchdog decided and acted on during the last day
224
+ agents watchdog history --since 24h
225
+
226
+ # Follow one session's Watchdog decisions
227
+ agents watchdog history <sessionId>
228
+
184
229
  # Leave one session detected-but-untouched
185
230
  agents watchdog policy <sessionId> handsoff
186
231
 
@@ -302,6 +347,46 @@ export function registerWatchdogCommand(program) {
302
347
  console.log(` ${chalk.magenta(r.phase.padEnd(12))} ${chalk.bold(r.sessionId.slice(0, 8))} → ${r.newSessionId.slice(0, 8)}${r.error ? chalk.red(` ${r.error}`) : ''}`);
303
348
  }
304
349
  console.log(`state dir: ${chalk.dim(stateDir())}`);
350
+ console.log(`history: ${chalk.dim('agents watchdog history')}`);
351
+ });
352
+ cmd.command('history [sessionId]')
353
+ .description('Show persisted Watchdog decisions and actions, newest first.')
354
+ .option('--limit <count>', 'Maximum events to show', '50')
355
+ .option('--since <duration>', 'Only events within a duration such as 2h or 7d')
356
+ .option('--all', 'Include heartbeat tick events')
357
+ .option('--json', 'Emit safe structured history (transcript content is excluded)')
358
+ .action((sessionId, opts, command) => {
359
+ const globals = command.optsWithGlobals();
360
+ const limit = Number.parseInt(opts.limit, 10);
361
+ if (!Number.isInteger(limit) || limit < 1) {
362
+ throw new Error('--limit must be a positive integer');
363
+ }
364
+ const sinceSeconds = opts.since === undefined ? undefined : parseDuration(opts.since);
365
+ if (sinceSeconds !== undefined && (sinceSeconds === null || sinceSeconds <= 0)) {
366
+ throw new Error('--since must be a positive duration such as 2h or 7d');
367
+ }
368
+ const sinceMs = sinceSeconds == null ? undefined : sinceSeconds * 1000;
369
+ const entries = selectWatchdogHistory(readWatchdogEvents(), {
370
+ limit,
371
+ sinceMs,
372
+ sessionId,
373
+ includeTicks: opts.all === true,
374
+ });
375
+ if (globals.json === true) {
376
+ console.log(JSON.stringify({ logPath: WATCHDOG_LOG_PATH, events: entries }, null, 2));
377
+ return;
378
+ }
379
+ if (entries.length === 0) {
380
+ console.log(chalk.dim(`No Watchdog events found in ${WATCHDOG_LOG_PATH}`));
381
+ return;
382
+ }
383
+ for (const entry of entries) {
384
+ const when = new Date(entry.ts).toLocaleString();
385
+ const session = entry.sessionId ? entry.sessionId.slice(0, 8) : '-';
386
+ const reason = entry.reason ? ` · ${entry.reason}` : '';
387
+ console.log(`${chalk.dim(when)} ${entry.kind.padEnd(8)} ${chalk.bold(session)} ${entry.agent ?? '-'} ${entry.message}${reason}`);
388
+ }
389
+ console.log(chalk.dim(`${entries.length} event${entries.length === 1 ? '' : 's'} · ${WATCHDOG_LOG_PATH}`));
305
390
  });
306
391
  // --- per-session policy ----------------------------------------------------
307
392
  cmd.command('policy <sessionId> <policy>')
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { detectDevBuild } from './lib/startup/dev-build.js';
14
+ import { configureRootCommand } from './lib/startup/root-command.js';
14
15
  // `ora`, `@inquirer/prompts`, `./commands/utils.js`, and the agents/versions/shims
15
16
  // modules are imported dynamically at their use sites: they are needed only on
16
17
  // interactive / update / shim-repair paths, never for fast commands like
@@ -71,6 +72,7 @@ if (process.argv[2] === SYNC_GET_CMD ||
71
72
  process.exit(code);
72
73
  }
73
74
  import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, installPackageIntoPrefix, installPackageWithBun, verifyInstalledVersion, refreshAliasShims, downloadVerifiedTarball, } from './lib/self-update.js';
75
+ import { registerUpgradeCommand } from './commands/upgrade.js';
74
76
  // Detect dev/working-tree builds and default the noisy startup steps off.
75
77
  // Three cases trip this:
76
78
  // 1. Dev install (scripts/install.sh) — package.json version stamped 0.0.0-dev.<sha>
@@ -140,14 +142,7 @@ if (process.argv[2] === '__daemon-tick') {
140
142
  // CLI presents its own name/help/errors as the brand. Unbranded (AGENTS_BRAND
141
143
  // unset) resolves to 'agents' and everything below is byte-identical to before.
142
144
  const BRAND = resolveBrandName();
143
- const program = new Command();
144
- program
145
- .name(BRAND)
146
- .description('Environment manager for AI agents')
147
- .version(VERSION)
148
- .option('--verbose', 'Show startup self-heal details on stderr')
149
- .helpOption('-h, --help', 'Show help')
150
- .addHelpCommand(false);
145
+ const program = configureRootCommand(new Command(), BRAND, VERSION);
151
146
  // ─── Audit backbone ────────────────────────────────────────────────────────────
152
147
  // One choke point logs every `agents <module> <cmd>` invocation to the structured
153
148
  // event log — so team create/disband, agent run, secrets access, and everything
@@ -805,59 +800,56 @@ function registerInternalCommand(p) {
805
800
  process.exit(0);
806
801
  });
807
802
  }
808
- /** Self-upgrade command (`agents upgrade [version]`). */
809
- function registerUpgradeCommand(p) {
810
- p.command('upgrade')
811
- .description('Upgrade agents-cli to the latest version (or a specific [version])')
812
- .argument('[version]', 'Target version or dist-tag to install (default: latest)')
813
- .option('-y, --yes', 'Install without an interactive confirmation prompt')
814
- .action(async (version, options) => {
815
- const { default: ora } = await import('ora');
816
- const { confirm } = await import('@inquirer/prompts');
817
- const { isInteractiveTerminal, isPromptCancelled } = await import('./commands/utils.js');
818
- const target = version ?? 'latest';
819
- let spinner = ora(version ? `Resolving ${NPM_PACKAGE_NAME}@${target}...` : 'Checking for updates...').start();
820
- try {
821
- const metadata = await fetchNpmPackageMetadata(target);
822
- const resolvedVersion = metadata.version;
823
- if (resolvedVersion === VERSION) {
824
- spinner.succeed(`Already on ${VERSION}`);
825
- return;
826
- }
827
- // For `latest` (no explicit version) skip when already ahead. When a
828
- // version is named explicitly, honor it even if it's a downgrade.
829
- if (!version && compareVersions(resolvedVersion, VERSION) <= 0) {
830
- spinner.succeed(`Already ahead of latest (${VERSION} >= ${resolvedVersion})`);
803
+ /** Runtime action for the shared `agents upgrade [version]` command definition. */
804
+ async function runUpgrade(version, options) {
805
+ const { default: ora } = await import('ora');
806
+ const { confirm } = await import('@inquirer/prompts');
807
+ const { isInteractiveTerminal, isPromptCancelled } = await import('./commands/utils.js');
808
+ const target = version ?? 'latest';
809
+ let spinner = ora(version ? `Resolving ${NPM_PACKAGE_NAME}@${target}...` : 'Checking for updates...').start();
810
+ try {
811
+ const metadata = await fetchNpmPackageMetadata(target);
812
+ const resolvedVersion = metadata.version;
813
+ if (resolvedVersion === VERSION) {
814
+ spinner.succeed(`Already on ${VERSION}`);
815
+ return;
816
+ }
817
+ // For `latest` (no explicit version) skip when already ahead. When a
818
+ // version is named explicitly, honor it even if it's a downgrade.
819
+ if (!version && compareVersions(resolvedVersion, VERSION) <= 0) {
820
+ spinner.succeed(`Already ahead of latest (${VERSION} >= ${resolvedVersion})`);
821
+ return;
822
+ }
823
+ const direction = compareVersions(resolvedVersion, VERSION) < 0 ? 'Downgrade' : 'Upgrade';
824
+ spinner.succeed(`Resolved ${NPM_PACKAGE_NAME}@${resolvedVersion}`);
825
+ printResolvedPackage(metadata);
826
+ if (isInteractiveTerminal() && !options.yes) {
827
+ const approved = await confirm({
828
+ message: `Install ${NPM_PACKAGE_NAME}@${resolvedVersion}?`,
829
+ default: false,
830
+ });
831
+ if (!approved) {
832
+ console.log(chalk.gray('Upgrade cancelled'));
831
833
  return;
832
834
  }
833
- const direction = compareVersions(resolvedVersion, VERSION) < 0 ? 'Downgrade' : 'Upgrade';
834
- spinner.succeed(`Resolved ${NPM_PACKAGE_NAME}@${resolvedVersion}`);
835
- printResolvedPackage(metadata);
836
- if (isInteractiveTerminal() && !options.yes) {
837
- const approved = await confirm({
838
- message: `Install ${NPM_PACKAGE_NAME}@${resolvedVersion}?`,
839
- default: false,
840
- });
841
- if (!approved) {
842
- console.log(chalk.gray('Upgrade cancelled'));
843
- return;
844
- }
845
- }
846
- spinner = ora(`${direction === 'Downgrade' ? 'Downgrading' : 'Upgrading'} ${VERSION} -> ${resolvedVersion}...`).start();
847
- await installResolvedPackage(metadata);
848
- spinner.succeed(`${direction}d to ${resolvedVersion}`);
849
- // Only show the changelog for a genuine upgrade range.
850
- if (compareVersions(resolvedVersion, VERSION) > 0) {
851
- await showWhatsNew(VERSION, resolvedVersion);
852
- }
853
835
  }
854
- catch (err) {
855
- if (isPromptCancelled(err))
856
- return;
857
- spinner.fail(`Upgrade failed: ${err instanceof Error ? err.message : String(err)}`);
858
- console.log(chalk.gray(`Run manually: agents upgrade ${version ? version + ' ' : ''}--yes`));
836
+ spinner = ora(`${direction === 'Downgrade' ? 'Downgrading' : 'Upgrading'} ${VERSION} -> ${resolvedVersion}...`).start();
837
+ await installResolvedPackage(metadata);
838
+ spinner.succeed(`${direction}d to ${resolvedVersion}`);
839
+ // Only show the changelog for a genuine upgrade range.
840
+ if (compareVersions(resolvedVersion, VERSION) > 0) {
841
+ await showWhatsNew(VERSION, resolvedVersion);
859
842
  }
860
- });
843
+ }
844
+ catch (err) {
845
+ if (isPromptCancelled(err))
846
+ return;
847
+ spinner.fail(`Upgrade failed: ${err instanceof Error ? err.message : String(err)}`);
848
+ console.log(chalk.gray(`Run manually: agents upgrade ${version ? version + ' ' : ''}--yes`));
849
+ }
850
+ }
851
+ function registerUpgradeRuntimeCommand(p) {
852
+ registerUpgradeCommand(p, runUpgrade);
861
853
  }
862
854
  // --- Lazy registration orchestration -----------------------------------------
863
855
  /** Import a command module via its loader and register it on the program. */
@@ -905,7 +897,7 @@ async function registerEagerForRequest(name) {
905
897
  registerInternalCommand(program);
906
898
  return true;
907
899
  case 'upgrade':
908
- registerUpgradeCommand(program);
900
+ registerUpgradeRuntimeCommand(program);
909
901
  return true;
910
902
  }
911
903
  const loaders = COMMAND_LOADERS[name];
@@ -1008,7 +1000,7 @@ async function registerAllEagerCommands() {
1008
1000
  registerJobsCronAliasCommand(program, 'jobs');
1009
1001
  registerJobsCronAliasCommand(program, 'cron');
1010
1002
  registerInternalCommand(program);
1011
- registerUpgradeCommand(program);
1003
+ registerUpgradeRuntimeCommand(program);
1012
1004
  await reg(loadPull);
1013
1005
  await reg(loadPush);
1014
1006
  await reg(loadRepo);
@@ -1351,6 +1351,12 @@ const CREDENTIAL_FILE_SEGMENTS = {
1351
1351
  // Muse Code stores OAuth / API credentials at ~/.config/muse/auth.json
1352
1352
  // (or META_API_KEY in the environment, which is not a file).
1353
1353
  muse: [['.config', 'muse', 'auth.json']],
1354
+ // Cursor's OAuth token — the login gate — is at $XDG_CONFIG_HOME/cursor/auth.json
1355
+ // (~/.config/cursor/auth.json by default). cli-config.json holds only account
1356
+ // metadata; the token file is what a real launch authenticates from, so a
1357
+ // version home with no token of its own is genuinely logged out once runs pin
1358
+ // XDG_CONFIG_HOME per home (see buildExecEnv).
1359
+ cursor: [['.config', 'cursor', 'auth.json']],
1354
1360
  };
1355
1361
  /** Whether an agent's credential file exists under a given home. */
1356
1362
  function credentialFileExistsUnder(agentId, home) {