@phnx-labs/agents-cli 1.22.57 → 1.22.59

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 (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -245,12 +245,17 @@ function renderRoutineRows({ jobs, scheduler, overdueSet, link, now, local = tru
245
245
  : lastStatus === 'missed' ? chalk.magenta
246
246
  : chalk.gray;
247
247
  const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
248
+ // Append the concrete reason a routine did not complete (auth_failed, wedged,
249
+ // blocked, missed) right in the Last Status cell, so the list answers "why"
250
+ // without a drill-in. Only for non-completed local runs; peer rows stay blank.
251
+ const reason = latestRun ? runFailureReason(latestRun) : null;
252
+ const reasonTag = reason ? chalk.gray(` — ${reason}`) : '';
248
253
  const agentLabelPadded = job.command
249
254
  ? chalk.magenta('command'.padEnd(10))
250
255
  : job.workflow
251
256
  ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
252
257
  : (job.agent || '').padEnd(10);
253
- console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
258
+ console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}${reasonTag}`);
254
259
  }
255
260
  }
256
261
  function parseRoutineTrigger(options) {
@@ -622,6 +627,43 @@ function routineMatchesQuery(job, q) {
622
627
  .toLowerCase()
623
628
  .includes(q);
624
629
  }
630
+ /** Friendly one-liners for the claim a `skipped` run lost. */
631
+ const SKIP_REASON_LABEL = {
632
+ active_run: 'wedged: a prior run is still active',
633
+ duplicate_slot: 'duplicate slot (already fired)',
634
+ wrong_owner: 'pinned to another device',
635
+ };
636
+ /**
637
+ * The short, human reason a run did not simply complete — for inline display in
638
+ * the list/detail so "why did it fail" needs no dig into the run dir. Prefers the
639
+ * concrete `errorMessage` (which carries `auth_failed: …`, OAuth-revoked, timeouts),
640
+ * then the readiness block for a `blocked` run, then the mapped skip reason. Returns
641
+ * null for a healthy (`completed`/`running`) run, which needs no annotation.
642
+ */
643
+ export function runFailureReason(run) {
644
+ if (run.status === 'completed' || run.status === 'running')
645
+ return null;
646
+ const compact = (s) => {
647
+ const one = s.replace(/\s+/g, ' ').trim();
648
+ return one.length > 80 ? one.slice(0, 79) + '…' : one;
649
+ };
650
+ if (run.errorMessage)
651
+ return compact(run.errorMessage);
652
+ if (run.status === 'blocked' && run.readiness) {
653
+ return compact(run.readiness.message || run.readiness.code);
654
+ }
655
+ if (run.skipReason)
656
+ return SKIP_REASON_LABEL[run.skipReason];
657
+ if (run.status === 'missed')
658
+ return 'scheduler was not running when it came due';
659
+ // The most common failure shape: a command/agent body that exited nonzero with
660
+ // no structured errorMessage (the cause is in stdout). Naming the exit code is
661
+ // still more than the bare status word, and tells the reader it ran and threw.
662
+ if ((run.status === 'failed' || run.status === 'timeout') && run.exitCode !== null && run.exitCode !== undefined) {
663
+ return `exit ${run.exitCode}`;
664
+ }
665
+ return null;
666
+ }
625
667
  /** One compact routine row for the browser list: name · kind · schedule · next · last. */
626
668
  function routineBrowserRow(job, scheduler, overdueSet, now) {
627
669
  const kind = job.command ? 'command' : job.workflow ? `wf:${job.workflow}` : job.agent ?? '?';
@@ -685,7 +727,12 @@ function buildRoutineDetail(job, scheduler, now) {
685
727
  ? chalk.red(run.status)
686
728
  : chalk.yellow(run.status);
687
729
  const dur = run.completedAt ? ` ${formatRunDuration(run.startedAt, run.completedAt)}` : '';
688
- lines.push(` ${run.startedAt} ${status}${dur}`);
730
+ // Surface WHY a run did not complete, inline, so "looking at status" does not
731
+ // require digging into the run dir. auth_failed / OAuth-revoked, a wedged
732
+ // active-run skip, or a readiness block all live on the RunMeta already.
733
+ const reason = runFailureReason(run);
734
+ const why = reason ? chalk.gray(` — ${reason}`) : '';
735
+ lines.push(` ${run.startedAt} ${status}${dur}${why}`);
689
736
  }
690
737
  }
691
738
  // 4. Stats
@@ -1533,7 +1580,9 @@ export function registerRoutinesCommands(program) {
1533
1580
  : run.status === 'failed'
1534
1581
  ? chalk.red(run.status)
1535
1582
  : chalk.yellow(run.status);
1536
- console.log(` ${run.runId} ${status} ${run.startedAt}`);
1583
+ const reason = runFailureReason(run);
1584
+ const why = reason ? chalk.gray(` — ${reason}`) : '';
1585
+ console.log(` ${run.runId} ${status} ${run.startedAt}${why}`);
1537
1586
  }
1538
1587
  });
1539
1588
  routinesCmd
@@ -1856,6 +1905,11 @@ export function registerRoutinesCommands(program) {
1856
1905
  chalk.gray(` ${run.startedAt}`) +
1857
1906
  chalk.gray(formatRunDuration(run.startedAt, run.completedAt)) +
1858
1907
  (run.exitCode !== null && run.exitCode !== undefined ? chalk.gray(` exit ${run.exitCode}`) : ''));
1908
+ // The structured reason (auth_failed, blocked readiness, wedged skip) — the
1909
+ // report/stdout tail below often buries or omits it, so name it up front.
1910
+ const logsReason = runFailureReason(run);
1911
+ if (logsReason)
1912
+ console.log(chalk.red('reason: ') + chalk.gray(logsReason));
1859
1913
  console.log(chalk.gray('─'.repeat(60)));
1860
1914
  const reportPath = path.join(getRunDir(name, runId), 'report.md');
1861
1915
  if (fs.existsSync(reportPath)) {
@@ -149,6 +149,11 @@ export function createDaemonHarness(fileSlug) {
149
149
  // and kills real tmux-wrapped processes every five-minute tick (RUSH-2545).
150
150
  AGENTS_HISTORY_DIR: path.join(home, '.agents', '.history'),
151
151
  AGENTS_SKIP_MIGRATION: '1',
152
+ // PHNX-2545 test-home tripwire: name the isolated home this daemon must
153
+ // resolve its state dir under. If the HOME override above ever failed to
154
+ // reach the child, runDaemon()'s assertTestDaemonHome() refuses to boot
155
+ // instead of ticking its scheduler against the operator's real host.
156
+ AGENTS_DAEMON_TEST_HOME: home,
152
157
  },
153
158
  detached: true,
154
159
  stdio: 'ignore',
@@ -11,7 +11,8 @@
11
11
  * Compat: positional text still works (`agents send "hi" --channel … --to …`).
12
12
  *
13
13
  * `agents notify` is the same delivery path with owner defaults
14
- * (`send --to owner`). Not a second stack. Not agent control — use
14
+ * (`send --to owner`) and is deprecated — new callers should use
15
+ * `agents feed post`. Not a second stack. Not agent control — use
15
16
  * `agents message` / `agents sessions inject` for running agents.
16
17
  *
17
18
  * Feed / activity are a different plane (record + read); feed.broadcast may
@@ -47,7 +47,7 @@ async function runSend(positionalText, opts, ownerMode) {
47
47
  }
48
48
  const SHARED_NOTES = `
49
49
  Planes (do not mix them up):
50
- send / notify - DELIVER a message to a recipient (this command)
50
+ send - DELIVER a message to a recipient (this command)
51
51
  feed post - RECORD progress / milestones (optional broadcast may call send)
52
52
  activity - READ the activity stream (not a send path)
53
53
  message / inject - CONTROL a running agent (mailbox answer or terminal keystroke)
@@ -97,7 +97,7 @@ export function registerSendCommand(program) {
97
97
  });
98
98
  const notifyCmd = program
99
99
  .command('notify [text]')
100
- .description('Deliver to the owner (alias of send --to owner). Channel + target default from notify.owner in agents.yaml.')
100
+ .description('[DEPRECATED] Deliver to the owner (alias of send --to owner). Use "agents feed post" for new code.')
101
101
  .option('--text <text>', 'message body (preferred over positional text)')
102
102
  .option('--channel <name>', 'override owner channel')
103
103
  .option('--to <target>', 'override owner target (or pass a non-owner dest with --channel)')
@@ -110,7 +110,7 @@ export function registerSendCommand(program) {
110
110
  .option('--dry-run', 'resolve + build but do not send');
111
111
  setHelpSections(notifyCmd, {
112
112
  examples: `
113
- # Same as: agents send --to owner --text "…"
113
+ # Deprecated: prefer "agents feed post --title \"…\" \"…\" --level important"
114
114
  agents notify --text "Build finished — PR #1346 is green"
115
115
  agents notify "legacy positional still works"
116
116
 
@@ -120,13 +120,15 @@ export function registerSendCommand(program) {
120
120
  agents notify --text "probe" --dry-run --json
121
121
  `,
122
122
  notes: `
123
- notify ≡ send --to owner. One delivery stack (lib/channels). Set
124
- notify.owner.{channel,to} in agents.yaml once per machine/fleet.
123
+ DEPRECATED. notify ≡ send --to owner and still works, but new callers
124
+ should use "agents feed post" (record + optional broadcast) instead.
125
+ Set notify.owner.{channel,to} in agents.yaml once per machine/fleet.
125
126
 
126
127
  ${SHARED_NOTES}
127
128
  `,
128
129
  });
129
130
  notifyCmd.action(async (text, opts) => {
131
+ console.error(chalk.yellow('Warning: "agents notify" is deprecated. Use "agents feed post" for progress posts that can also reach the owner.'));
130
132
  await runSend(text, opts, true);
131
133
  });
132
134
  }
@@ -1,6 +1,7 @@
1
1
  import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
2
2
  import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
3
3
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
4
+ import type { FileChange } from '../lib/session/digest.js';
4
5
  import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
5
6
  /**
6
7
  * Compact checklist tally for list rows and previews (RUSH-2045).
@@ -118,6 +119,16 @@ export interface SessionPreviewDigest {
118
119
  backgroundShellCount?: number;
119
120
  toolTags: string[];
120
121
  changes: ReturnType<typeof changeCounts>;
122
+ /**
123
+ * The per-file source paths behind `changes`. `changes` keeps the roll-up
124
+ * counts every existing consumer already reads; `changedFiles` carries the
125
+ * real path + op the CLI computed at scan time (via classifyFileChanges) and
126
+ * used to discard — kept so a consumer that renders a per-file diff list (the
127
+ * AGI EXT Fleet detail panel, PHNX-2973) has the paths, not just the totals.
128
+ * Capped so a session that rewrote thousands of files can't bloat the cached
129
+ * digest / JSON payload; the `changes` counts stay the true totals.
130
+ */
131
+ changedFiles: FileChange[];
121
132
  dirs: string[];
122
133
  repos: string[];
123
134
  artifacts: ReturnType<typeof extractArtifacts>;
@@ -229,6 +229,14 @@ export function sanitizeRemoteDigest(raw) {
229
229
  backgroundShellCount: optNum(d.backgroundShellCount),
230
230
  toolTags: strList(d.toolTags),
231
231
  changes,
232
+ // Per-file paths from an untrusted peer: keep only well-formed {path, op}
233
+ // entries (path scrubbed of terminal escapes, op a known FileOp), bounded
234
+ // like the local build so a hostile peer can't flood the pane.
235
+ changedFiles: objList(d.changedFiles, (f) => {
236
+ const path = str(f.path);
237
+ const op = f.op === 'created' || f.op === 'modified' || f.op === 'deleted' ? f.op : undefined;
238
+ return path && op ? { path, op } : undefined;
239
+ }).slice(0, CHANGED_FILES_MAX),
232
240
  dirs: strList(d.dirs),
233
241
  repos: strList(d.repos),
234
242
  artifacts: objList(d.artifacts, (a) => {
@@ -655,6 +663,10 @@ const LAST_RESPONSE_MAX_LINES = 15;
655
663
  const LAST_RESPONSE_MAX_LINES_WITH_TODOS = 8;
656
664
  const TODOS_MAX_ITEMS = 5;
657
665
  const DIRS_TOUCHED_MAX = 5;
666
+ // Upper bound on the per-file `changedFiles` list carried on the digest. High
667
+ // enough to cover any real session's edits, low enough that a runaway rewrite
668
+ // can't bloat the cached JSON. The `changes` counts stay the true totals.
669
+ const CHANGED_FILES_MAX = 200;
658
670
  /** Fold a harness-normalized event stream into the stable preview data model. */
659
671
  export function buildSessionPreviewDigest(events, session) {
660
672
  let firstUser = '';
@@ -747,6 +759,10 @@ export function buildSessionPreviewDigest(events, session) {
747
759
  backgroundShellCount,
748
760
  toolTags: [...toolTags],
749
761
  changes: chg,
762
+ // Full per-file list the picker used to collapse to `chg` and throw away.
763
+ // Bounded so a mass-rewrite session can't blow up the cached digest; the
764
+ // counts above remain the true totals.
765
+ changedFiles: changes.slice(0, CHANGED_FILES_MAX),
750
766
  dirs: directoriesTouched(session, events, changes),
751
767
  repos: extractRepos(events, session.cwd),
752
768
  artifacts: extractArtifacts(changes),
@@ -6,6 +6,19 @@ import { discoverPlugins } from '../lib/plugins/plugins.js';
6
6
  import { setHelpSections } from '../lib/help.js';
7
7
  import { terminalWidth, truncateToWidth, padToWidth, stringWidth } from '../lib/session/width.js';
8
8
  const DEFAULT_TOP = 20;
9
+ /**
10
+ * The recorded-set the zero-counts must be read against (PHNX-2301). A resource
11
+ * only records an EXPLICIT invocation from a harness that emits the event:
12
+ * `Skill` tool calls come from Claude + Kimi, slash-commands from Claude only
13
+ * (`SKILL_TOOL_NAME_BY_AGENT` in `lib/session/highlights.ts`, slash-command
14
+ * parsing in `lib/session/parse.ts`). A session under any other harness — or an
15
+ * auto-triggered skill, which emits no event on any harness — contributes
16
+ * nothing, so its resources read as 0 whether or not they were used. Keep these
17
+ * in lockstep with the writer: widening them here without a verified transcript
18
+ * tool-name would make the caveat lie about coverage it does not have.
19
+ */
20
+ const RECORDING_SKILL_HARNESSES = ['claude', 'kimi'];
21
+ const RECORDING_COMMAND_HARNESSES = ['claude'];
9
22
  /**
10
23
  * The both-ends "dead weight" set: installed resources whose identity
11
24
  * (kind + name — name already embeds `plugin:short`, so it matches the stored
@@ -128,7 +141,7 @@ async function statsAction(cmd) {
128
141
  const totalInvocations = allInvoked.reduce((s, r) => s + r.invocations, 0);
129
142
  if (opts.json) {
130
143
  process.stdout.write(JSON.stringify({
131
- schemaVersion: 1,
144
+ schemaVersion: 2,
132
145
  kind: 'sessions-stats',
133
146
  generatedAt: new Date().toISOString(),
134
147
  filters: {
@@ -142,9 +155,21 @@ async function statsAction(cmd) {
142
155
  signal: {
143
156
  explicitOnly: true,
144
157
  note: 'Explicit invocations only (slash commands + Skill tool calls). Auto-triggered skills emit no event and read as 0. Skill invocations are recorded for Claude and Kimi; slash-commands for Claude only.',
158
+ // Structured recorded-set so a machine consumer can tell a zero from a
159
+ // non-recording harness apart from a genuine non-use (PHNX-2301): a
160
+ // zeroInvoked resource may simply have been auto-triggered, or only
161
+ // used under a harness not in these sets.
162
+ recording: {
163
+ skills: RECORDING_SKILL_HARNESSES,
164
+ commands: RECORDING_COMMAND_HARNESSES,
165
+ },
145
166
  },
146
167
  coverage: {
168
+ // sessionsWithUsage/sessionsIndexed kept for the SES-IF-4b envelope
169
+ // contract; sessionsScanned is the honest backfill-coverage numerator
170
+ // (ledger rows), distinct from the absolute sessionsWithUsage count.
147
171
  sessionsWithUsage: coverage.covered,
172
+ sessionsScanned: coverage.scanned,
148
173
  sessionsIndexed: coverage.total,
149
174
  },
150
175
  totals: {
@@ -210,8 +235,12 @@ function renderHuman(args) {
210
235
  out.push(chalk.bold('Resource usage') +
211
236
  chalk.gray(` · ${allInvokedCount} invoked · ${totalInvocations} invocation${totalInvocations !== 1 ? 's' : ''}` +
212
237
  (scopeBits.length ? ` · ${scopeBits.join(' · ')}` : '')));
213
- out.push(chalk.gray(` coverage: ${coverage.covered}/${coverage.total} sessions carry the signal`) +
214
- (coverage.total > 0 && coverage.covered / coverage.total < 0.5
238
+ // Scan coverage (ledger) is the honest "has the backfill folded in history?"
239
+ // signal and drives the hint; sessionsWithUsage is an ABSOLUTE count of sessions
240
+ // carrying ≥1 explicit invocation, which stays small at full scan coverage
241
+ // because most sessions invoke nothing (PHNX-2301).
242
+ out.push(chalk.gray(` coverage: ${coverage.scanned}/${coverage.total} sessions scanned · ${coverage.covered} carry an explicit invocation`) +
243
+ (coverage.total > 0 && coverage.scanned / coverage.total < 0.5
215
244
  ? chalk.yellow(' — run `agents sessions backfill resources` to fold in history')
216
245
  : ''));
217
246
  out.push(chalk.gray(' signal: explicit invocations only (slash commands + Skill tool); auto-triggered skills read as 0; skills from Claude+Kimi, slash-commands Claude-only'));
@@ -228,8 +257,11 @@ function renderHuman(args) {
228
257
  }
229
258
  out.push('');
230
259
  }
231
- // Zero-invoked section (the dead weight).
232
- out.push(chalk.bold('Installed but never invoked') + chalk.gray(` (${zeroInvoked.length})`));
260
+ // Zero-invoked section (the dead weight). "Never invoked" is scoped to the
261
+ // recorded set: never EXPLICITLY invoked under a recording harness (skills
262
+ // Claude+Kimi, commands Claude-only) — it may still have been auto-triggered or
263
+ // used under another harness, which is why this is dead-weight EVIDENCE, not proof.
264
+ out.push(chalk.bold('Installed but never explicitly invoked') + chalk.gray(` (${zeroInvoked.length})`));
233
265
  if (zeroInvoked.length === 0) {
234
266
  out.push(chalk.gray(' (every installed resource has at least one explicit invocation in this window)'));
235
267
  }
@@ -2061,6 +2061,7 @@ export async function renderSessionPreview(query, scope) {
2061
2061
  tokenCount: session.tokenCount,
2062
2062
  costUsd: session.costUsd,
2063
2063
  label: session.label,
2064
+ topic: session.topic,
2064
2065
  ticketId: session.ticketId,
2065
2066
  prUrl: session.prUrl,
2066
2067
  },
@@ -2315,9 +2316,16 @@ limitSource) {
2315
2316
  return;
2316
2317
  }
2317
2318
  // --device / --devices values are merged into options.host for internal routing. A bare `all` / `fleet` sentinel means
2318
- // "search every peer" — which is already the default — so it resolves to no
2319
- // explicit host set rather than erroring on a device literally named "all".
2320
- const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
2319
+ // "search every peer" — which is already the default for `--active` and the
2320
+ // interactive listing — so it resolves to no explicit host set rather than
2321
+ // erroring on a device literally named "all".
2322
+ const rawDeviceTargets = [...(options.device ?? []), ...(options.devices ?? [])];
2323
+ // But the HISTORICAL `--json` listing does NOT fan out by default (it stays a
2324
+ // deterministic local slice for scripts), so the sentinel would otherwise be
2325
+ // silently dropped and the fleet never reached (PHNX-2673). Remember it so that
2326
+ // path can sweep every peer, exactly as the interactive listing already does.
2327
+ const fleetWide = rawDeviceTargets.some(t => t.toLowerCase() === 'all' || t.toLowerCase() === 'fleet');
2328
+ const deviceTargets = rawDeviceTargets
2321
2329
  .filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
2322
2330
  if (deviceTargets.length > 0) {
2323
2331
  options.host = [...(options.host ?? []), ...deviceTargets];
@@ -2774,16 +2782,43 @@ limitSource) {
2774
2782
  // each peer) would fall to FTS content search and return every transcript
2775
2783
  // that merely MENTIONS the id, defeating exact remote resolution. A genuine
2776
2784
  // search phrase keeps the ranked metadata+content path.
2777
- const filtered = searchQuery
2785
+ let filtered = searchQuery
2778
2786
  ? resolveSessionQuery(sessions, searchQuery, {
2779
2787
  scope: { agent: options.agent, project: options.project, routine: options.routine },
2780
2788
  }).matches
2781
2789
  : sessions;
2790
+ // `--device all`/`--fleet` on a historical `--json` query fans out to every
2791
+ // peer and merges their rows in — the SAME SSH sweep the interactive listing
2792
+ // below uses, and the whole-fleet twin of the explicit `--device <host>
2793
+ // --json` path (`runRemoteSessionsJson`). Without this the documented
2794
+ // "search the whole fleet" flag returned local rows only (PHNX-2673). Guarded
2795
+ // to the sentinel so a bare `--json` stays a deterministic local slice for
2796
+ // scripts, and skipped under `--local`/the peer recursion guard. Dead peers
2797
+ // are skipped by the fan-out, never fatal — enrichment, not a dependency.
2798
+ const forceLocalJson = options.local === true || process.env[NO_FANOUT_ENV] === '1';
2799
+ if (fleetWide && !forceLocalJson) {
2800
+ const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
2801
+ if (!forwarded.includes('--json'))
2802
+ forwarded.push('--json');
2803
+ const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2804
+ try {
2805
+ const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
2806
+ if (remoteSessions.length > 0) {
2807
+ filtered = mergeLocalFirst([...filtered, ...remoteSessions], machineId());
2808
+ }
2809
+ }
2810
+ catch {
2811
+ // fan-out is an enrichment, never a hard dependency
2812
+ }
2813
+ finally {
2814
+ fanSpinner?.stop();
2815
+ }
2816
+ }
2782
2817
  // JSON is the canonical picker contract: enrich the durable rows once
2783
2818
  // from the shared live cache so every consumer gets lifecycle/recovery
2784
2819
  // metadata without performing its own join or transcript scan.
2785
2820
  const live = await gatherActiveSessions({
2786
- local: options.local === true || process.env[NO_FANOUT_ENV] === '1',
2821
+ local: forceLocalJson,
2787
2822
  hosts: options.host,
2788
2823
  });
2789
2824
  process.stdout.write(serializeSessionsJson(serializeSessionPickerRows(filtered, live.sessions)));
@@ -250,6 +250,17 @@ export interface ShareUpdateResult {
250
250
  templateHash: string;
251
251
  baseUrl: string;
252
252
  workerName: string;
253
+ /** True when this was a `--check` run: nothing was deployed, the fields below
254
+ * just report whether a deploy WOULD be needed. Used by the release train to
255
+ * decide during preflight — before it verifies deploy creds — whether this
256
+ * release even changes the Worker. */
257
+ checked?: boolean;
258
+ /** Whether the current `worker-template.ts` render differs from what the
259
+ * endpoint last deployed (or `--force`/an endpoint with no recorded hash). */
260
+ deployNeeded?: boolean;
261
+ /** The template hash the endpoint last deployed, per local config. Undefined
262
+ * on an endpoint provisioned before the hash field existed. */
263
+ deployedHash?: string;
253
264
  }
254
265
  /** Re-deploy the Worker script on an ALREADY-provisioned endpoint to match the
255
266
  * current `worker-template.ts`. Reuses the existing account/worker/bucket and
@@ -264,6 +275,9 @@ export declare function runShareUpdate(opts?: {
264
275
  account?: string;
265
276
  token?: string;
266
277
  force?: boolean;
278
+ /** Report whether a deploy is needed and return without deploying. Needs no
279
+ * Cloudflare credentials — change detection is a pure local render+hash. */
280
+ check?: boolean;
267
281
  request?: CloudflareRequester;
268
282
  /** Bind PHOENIX_ID_BASE even when the configured hostname is not the managed domain. */
269
283
  managed?: boolean;
@@ -820,6 +820,7 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
820
820
  .option('--account <id>', 'Cloudflare account id override (default: the configured endpoint\'s account)')
821
821
  .option('--token <t>', 'Cloudflare API token (else read from --bundle)')
822
822
  .option('--force', 're-deploy even if the deployed template already matches')
823
+ .option('--check', 'report whether a deploy is needed and exit without deploying (needs no Cloudflare credentials)')
823
824
  // Named --update-json, not --json: `share <file>` (the parent) already
824
825
  // owns --json for its own publish-time result. Commander resolves an
825
826
  // option's long name against the WHOLE ancestor chain, so a same-named
@@ -832,6 +833,12 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
832
833
  console.log(JSON.stringify(result, null, 2));
833
834
  return;
834
835
  }
836
+ if (result.checked) {
837
+ console.log(result.deployNeeded
838
+ ? chalk.yellow(`Worker '${result.workerName}' template is outdated — deploy needed (current render ${result.templateHash.slice(0, 12)}…).`)
839
+ : chalk.dim(`Worker '${result.workerName}' already matches the current template — no deploy needed.`));
840
+ return;
841
+ }
835
842
  if (result.updated) {
836
843
  console.log(chalk.green(`Worker '${result.workerName}' updated → template ${result.templateHash.slice(0, 12)}…`));
837
844
  }
@@ -851,11 +858,18 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
851
858
 
852
859
  # Force a re-deploy even though the template hash already matches
853
860
  agents artifacts share update --force
861
+
862
+ # Report whether a deploy is due without deploying (no Cloudflare creds needed)
863
+ agents artifacts share update --check --update-json
854
864
  `,
855
865
  notes: `
856
866
  Reuses the existing account/worker/bucket from 'agents artifacts share status' and the
857
867
  existing write token — it never re-provisions a bucket, touches routes, or
858
868
  regenerates the token. See 'agents artifacts share status' for whether an update is due.
869
+
870
+ --check renders the current template and compares its hash to what the endpoint last
871
+ deployed, then exits — deploying nothing and reading no Cloudflare credentials. The
872
+ release train uses it to decide, before publish, whether a release changes the Worker.
859
873
  `,
860
874
  });
861
875
  shareCmd
@@ -1222,6 +1236,27 @@ export async function runShareUpdate(opts = {}) {
1222
1236
  if (!cfg) {
1223
1237
  throw new Error("Not configured. Run 'agents artifacts setup' (to provision) or 'agents artifacts share join' first.");
1224
1238
  }
1239
+ // Change detection is a pure local computation (render + sha256), so it needs
1240
+ // no Cloudflare credentials. The release train calls this in `--check` mode
1241
+ // during preflight — BEFORE it has verified the deploy creds — to decide
1242
+ // whether this release changes the Worker at all. `shareTemplateStatus` is the
1243
+ // same comparator `agents artifacts share status` uses; 'unknown' (an endpoint
1244
+ // with no recorded hash) deploys, mirroring `updateWorker`'s undefined-previous
1245
+ // behavior.
1246
+ const worker = renderWorkerBundle();
1247
+ const templateHash = hashWorkerScript(worker.script);
1248
+ const deployNeeded = opts.force === true || shareTemplateStatus(cfg) !== 'current';
1249
+ if (opts.check) {
1250
+ return {
1251
+ updated: false,
1252
+ checked: true,
1253
+ deployNeeded,
1254
+ templateHash,
1255
+ deployedHash: cfg.templateHash,
1256
+ baseUrl: cfg.baseUrl,
1257
+ workerName: cfg.workerName,
1258
+ };
1259
+ }
1225
1260
  const { apiToken, accountId: acctFromBundle } = readCloudflareCreds(opts.bundle ?? DEFAULT_CF_BUNDLE, {
1226
1261
  apiToken: opts.token,
1227
1262
  accountId: opts.account,
@@ -1231,7 +1266,6 @@ export async function runShareUpdate(opts = {}) {
1231
1266
  throw new Error("Share endpoint has no Cloudflare account id — `agents artifacts share update` cannot call the API. Pass --account <id>, or re-run 'agents artifacts share join'.");
1232
1267
  }
1233
1268
  const writeToken = readWriteToken();
1234
- const worker = renderWorkerBundle();
1235
1269
  const phoenixIdBase = phoenixIdBaseForDeploy({ managed: opts.managed }, cfg);
1236
1270
  const provisionOpts = {
1237
1271
  ...(opts.request ? { request: opts.request } : {}),
@@ -1242,7 +1276,14 @@ export async function runShareUpdate(opts = {}) {
1242
1276
  if (!result.skipped) {
1243
1277
  writeShareConfig({ ...cfg, accountId, templateHash: result.templateHash });
1244
1278
  }
1245
- return { updated: !result.skipped, templateHash: result.templateHash, baseUrl: cfg.baseUrl, workerName: cfg.workerName };
1279
+ return {
1280
+ updated: !result.skipped,
1281
+ templateHash: result.templateHash,
1282
+ deployNeeded,
1283
+ deployedHash: cfg.templateHash,
1284
+ baseUrl: cfg.baseUrl,
1285
+ workerName: cfg.workerName,
1286
+ };
1246
1287
  }
1247
1288
  function cleanHostname(domain) {
1248
1289
  const raw = domain?.trim().replace(/\/+$/, '');
@@ -130,7 +130,11 @@ function pctCell(v, width) {
130
130
  * header — which defeats the scannability this column exists for. */
131
131
  const SPEC_WIDTH_MIN = 12;
132
132
  /** The static hardware as one compact cell — `12c 64G 1T`: cores, total RAM,
133
- * total root disk via fmtBytes. `—` only when no probe has ever seen the box.
133
+ * total root disk via fmtBytes. `—` covers two cases: no probe has ever seen
134
+ * the box, or the probe answered but yielded no usable core count (`parseNcpu`
135
+ * finding none, or the Windows `ncpu` group failing the finite-and-positive
136
+ * check) — the cell is keyed on `ncpu` because a spec string without it would
137
+ * read as a machine with zero cores.
134
138
  *
135
139
  * Deliberately NOT gated on `reachable`: hardware does not change while a
136
140
  * machine is down, so an offline device keeps rendering the spec from its last
@@ -2298,6 +2302,13 @@ box; \`agents doctor\` names it.
2298
2302
 
2299
2303
  A box whose probe cannot answer (no POSIX shell, e.g. Windows) is reported
2300
2304
  \`unverified\` rather than counted as a success.
2305
+
2306
+ The upgrade OWNS its global bin links: after installing, it verifies that
2307
+ \`<prefix>/bin/{agents,ag,browser,computer}\` resolve to the freshly-installed
2308
+ copy and RESTORES any the package manager dropped — the state that once left a
2309
+ box upgraded in place but with every \`agents\` invocation "command not found"
2310
+ (PHNX-2768). A link it cannot make resolve fails the upgrade loud, so that box
2311
+ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
2301
2312
  `)
2302
2313
  .action(async (version) => {
2303
2314
  let cmd;
@@ -78,7 +78,7 @@ export function registerStatusCommand(syncCmd) {
78
78
  // A non-git / partial ~/.agents is its own drift state, not a per-agent
79
79
  // "N missing" — surface it distinctly so the real problem isn't buried (PHNX-3301).
80
80
  if (status.user.notGitRepo) {
81
- console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents repo sync user`')}`);
81
+ console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents sync` (or `agents repo sync user`)')}`);
82
82
  }
83
83
  if (status.agents.length === 0) {
84
84
  console.log(chalk.gray(' (no installed agent versions)'));