@phnx-labs/agents-cli 1.22.57 → 1.22.58

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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/bootstrap.js +8 -1
  3. package/dist/commands/accounts.js +7 -3
  4. package/dist/commands/apply.js +10 -2
  5. package/dist/commands/fork.d.ts +23 -10
  6. package/dist/commands/fork.js +115 -58
  7. package/dist/commands/monitors.js +11 -0
  8. package/dist/commands/prune.js +5 -3
  9. package/dist/commands/routines.d.ts +8 -0
  10. package/dist/commands/routines.js +57 -3
  11. package/dist/commands/sessions-picker.d.ts +11 -0
  12. package/dist/commands/sessions-picker.js +16 -0
  13. package/dist/commands/sessions.js +1 -0
  14. package/dist/commands/share.d.ts +14 -0
  15. package/dist/commands/share.js +43 -2
  16. package/dist/commands/status.js +1 -1
  17. package/dist/commands/sync.js +83 -7
  18. package/dist/commands/traces.js +7 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/lib/account-registry.d.ts +5 -1
  22. package/dist/lib/account-registry.js +47 -14
  23. package/dist/lib/accounting/capacity.d.ts +18 -7
  24. package/dist/lib/accounting/capacity.js +19 -8
  25. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  26. package/dist/lib/accounting/usage-sync.js +76 -2
  27. package/dist/lib/accounting/usage.js +7 -1
  28. package/dist/lib/auth-mint.d.ts +11 -1
  29. package/dist/lib/auth-mint.js +21 -6
  30. package/dist/lib/browser/ipc.d.ts +8 -0
  31. package/dist/lib/browser/ipc.js +87 -0
  32. package/dist/lib/browser/service.d.ts +19 -0
  33. package/dist/lib/browser/service.js +96 -11
  34. package/dist/lib/browser/sessions-list.js +10 -1
  35. package/dist/lib/daemon/runner.d.ts +3 -0
  36. package/dist/lib/daemon/runner.js +86 -45
  37. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  38. package/dist/lib/daemon/usage-sync-service.js +14 -8
  39. package/dist/lib/daemon-services.js +1 -1
  40. package/dist/lib/devices/connect.d.ts +17 -8
  41. package/dist/lib/devices/connect.js +31 -14
  42. package/dist/lib/doctor-diff.js +77 -7
  43. package/dist/lib/fleet/manifest.d.ts +17 -0
  44. package/dist/lib/fleet/manifest.js +26 -0
  45. package/dist/lib/hooks/install.d.ts +27 -11
  46. package/dist/lib/hooks/install.js +42 -17
  47. package/dist/lib/hosts/reconnect.d.ts +52 -203
  48. package/dist/lib/hosts/reconnect.js +64 -284
  49. package/dist/lib/installations/migrate.d.ts +6 -120
  50. package/dist/lib/installations/migrate.js +27 -259
  51. package/dist/lib/installations/shims.d.ts +13 -95
  52. package/dist/lib/installations/shims.js +22 -139
  53. package/dist/lib/installations/store.js +1 -1
  54. package/dist/lib/installations/versions.d.ts +26 -133
  55. package/dist/lib/installations/versions.js +41 -204
  56. package/dist/lib/plugins/skills.d.ts +8 -1
  57. package/dist/lib/plugins/skills.js +18 -2
  58. package/dist/lib/refresh.d.ts +9 -0
  59. package/dist/lib/refresh.js +3 -1
  60. package/dist/lib/routine-readiness.d.ts +15 -1
  61. package/dist/lib/routine-readiness.js +41 -0
  62. package/dist/lib/sandbox.d.ts +4 -1
  63. package/dist/lib/sandbox.js +30 -1
  64. package/dist/lib/secrets/agent.d.ts +80 -225
  65. package/dist/lib/secrets/agent.js +139 -401
  66. package/dist/lib/secrets/bundles.d.ts +73 -222
  67. package/dist/lib/secrets/bundles.js +168 -467
  68. package/dist/lib/secrets/reaper.d.ts +28 -70
  69. package/dist/lib/secrets/reaper.js +30 -85
  70. package/dist/lib/secrets/remote.d.ts +42 -129
  71. package/dist/lib/secrets/remote.js +55 -173
  72. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  73. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  74. package/dist/lib/self-heal/registry.js +2 -0
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/self-update.d.ts +23 -0
  77. package/dist/lib/self-update.js +50 -0
  78. package/dist/lib/session/active.d.ts +13 -1
  79. package/dist/lib/session/active.js +2 -0
  80. package/dist/lib/session/db.d.ts +20 -1
  81. package/dist/lib/session/db.js +139 -9
  82. package/dist/lib/session/fork.d.ts +45 -26
  83. package/dist/lib/session/fork.js +32 -95
  84. package/dist/lib/session/tool-calls.d.ts +43 -1
  85. package/dist/lib/session/tool-calls.js +74 -44
  86. package/dist/lib/session/tool-store.d.ts +33 -2
  87. package/dist/lib/session/tool-store.js +56 -3
  88. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  89. package/dist/lib/staleness/writers/sources.js +2 -1
  90. package/dist/lib/sync-status.d.ts +22 -0
  91. package/dist/lib/sync-status.js +27 -0
  92. package/dist/lib/sync-umbrella.d.ts +9 -0
  93. package/dist/lib/sync-umbrella.js +21 -2
  94. package/dist/lib/traces/insights.d.ts +47 -14
  95. package/dist/lib/traces/insights.js +92 -21
  96. package/dist/lib/traces/phenotype.d.ts +23 -3
  97. package/dist/lib/traces/phenotype.js +72 -24
  98. package/dist/lib/traces/sync.d.ts +15 -0
  99. package/dist/lib/traces/sync.js +104 -19
  100. package/dist/lib/traces/worker-template.js +154 -1
  101. package/package.json +1 -1
@@ -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)) {
@@ -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),
@@ -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
  },
@@ -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(/\/+$/, '');
@@ -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)'));
@@ -45,6 +45,7 @@ import { runLaunchSync } from '../lib/project-launch.js';
45
45
  import { formatKeptProjectResources } from '../lib/project-resources.js';
46
46
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
47
47
  import { runUmbrellaSync } from '../lib/sync-umbrella.js';
48
+ import { verifyVersionConverged, formatResidualDrift } from '../lib/sync-status.js';
48
49
  import { addHostOption } from '../lib/hosts/option.js';
49
50
  import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
50
51
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
@@ -53,6 +54,46 @@ import { registerStatusCommand } from './status.js';
53
54
  function emitJson(payload) {
54
55
  console.log(JSON.stringify(payload));
55
56
  }
57
+ /**
58
+ * Post-reconcile verification (PHNX-3186): after a sync writes into a set of
59
+ * version homes, re-read each home and confirm it now matches its resolved
60
+ * sources. The `agents sync` success line MUST NOT read "reconciled" while the
61
+ * drift it was asked to fix stays put. Any residual drifted/missing resource
62
+ * sets a non-zero exit code and — outside `--json` — names the exact unfixed
63
+ * drift so the operator sees what did not converge instead of a false ✓. Orphans
64
+ * are excluded (sync never removes them). Returns the residual for `--json`.
65
+ */
66
+ function verifyReconciled(pairs, cwd) {
67
+ const residual = [];
68
+ const seen = new Set();
69
+ for (const { agent, version } of pairs) {
70
+ const key = `${agent}@${version}`;
71
+ if (seen.has(key))
72
+ continue;
73
+ seen.add(key);
74
+ const r = verifyVersionConverged(agent, version, cwd);
75
+ if (r)
76
+ residual.push(r);
77
+ }
78
+ // Residual drift is reported loudly via `ok:false` + the printed ⚠ block, but
79
+ // does NOT change the exit code — matching the declined-write precedent
80
+ // (RUSH-2700). The fleet fan-out (`agents sync --device all`) THROWS on a
81
+ // non-zero peer exit (`hosts/passthrough.ts`), discarding that box's JSON, so a
82
+ // non-zero exit here would hide the very `residualDrift` payload it emitted.
83
+ // Callers that must treat an incomplete sync as failure read `ok`/`residualDrift`
84
+ // from `--json`, exactly as they already do for declines.
85
+ return residual;
86
+ }
87
+ /** Print the residual-drift block naming exactly what did not converge. */
88
+ function printResidual(residual, errLog) {
89
+ if (residual.length === 0)
90
+ return;
91
+ const lines = formatResidualDrift(residual);
92
+ errLog(chalk.yellow(`⚠ sync did not fully reconcile — ${lines.length} resource(s) still drift after writing:`));
93
+ for (const line of lines)
94
+ errLog(chalk.yellow(` ${line}`));
95
+ errLog(chalk.gray(' Re-run the sync; a gap that survives a re-run is a real unreconcilable drift — report it.'));
96
+ }
56
97
  /**
57
98
  * Translate per-kind CLI flags into a `ResourceSelection` for `buildSelection`.
58
99
  * Returns `undefined` when no kind flag was given (caller should use full sync).
@@ -429,6 +470,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
429
470
  await runInteractiveReconcile(opts, outLog, errLog);
430
471
  return;
431
472
  }
473
+ const cwd = opts.cwd || process.cwd();
432
474
  const flags = {
433
475
  repos: opts.repos,
434
476
  secrets: opts.secrets,
@@ -459,10 +501,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
459
501
  // --cloud (fetch-only, no local reconcile).
460
502
  if (!opts.cloud)
461
503
  evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
504
+ // Post-reconcile verification (PHNX-3186): re-read every version the reconcile
505
+ // wrote into and confirm it now matches source. Without this the umbrella
506
+ // printed `✓ sync: reconciled` unconditionally, the exact false-success the
507
+ // ticket reports. Residual drift downgrades the line and sets a non-zero exit.
508
+ const residual = result.reconciled
509
+ ? verifyReconciled(result.reconciledVersions.map((r) => ({ agent: r.agent, version: r.version })), cwd)
510
+ : [];
462
511
  if (json) {
463
512
  emitJson({
464
- // A refused resource is not a clean sync (RUSH-2700).
465
- ok: result.declined.length === 0,
513
+ // A refused resource OR residual drift is not a clean sync (RUSH-2700 +
514
+ // PHNX-3186).
515
+ ok: result.declined.length === 0 && residual.length === 0,
466
516
  mode: 'umbrella',
467
517
  plan: result.plan,
468
518
  repos: result.repos,
@@ -470,6 +520,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
470
520
  devices: result.devices,
471
521
  reconciled: result.reconciled,
472
522
  declined: result.declined,
523
+ residualDrift: residual,
473
524
  });
474
525
  return;
475
526
  }
@@ -482,12 +533,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
482
533
  if (result.secrets) {
483
534
  parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
484
535
  }
536
+ // Only claim "reconciled" when the reconcile actually converged. Residual
537
+ // drift is already printed loudly by verifyReconciled above; reflect it in
538
+ // the one-line summary rather than a bare ✓.
485
539
  if (result.reconciled)
486
- parts.push('reconciled');
487
- outLog(chalk.green(`✓ sync: ${parts.join(' · ') || 'nothing to do'}`));
540
+ parts.push(residual.length === 0 ? 'reconciled' : 'reconcile INCOMPLETE');
541
+ const symbol = residual.length === 0 ? chalk.green('✓') : chalk.yellow('⚠');
542
+ const line = `${symbol} sync: ${parts.join(' · ') || 'nothing to do'}`;
543
+ outLog(residual.length === 0 ? chalk.green(line) : chalk.yellow(line));
488
544
  const errs = [...(result.repos?.errors ?? []), ...(result.secrets?.errors ?? [])];
489
545
  for (const e of errs)
490
546
  errLog(chalk.yellow(` ! ${e}`));
547
+ printResidual(residual, errLog);
491
548
  }
492
549
  }
493
550
  catch (err) {
@@ -700,13 +757,23 @@ async function runSync(agentSpec, repoArg, opts) {
700
757
  if (!quiet && !json)
701
758
  printSyncDetail(result, agentId, v, cwd);
702
759
  }
760
+ // Verify each version actually converged; a repo-scoped sync only touched
761
+ // that repo's kinds, so skip verification there (the other layers legitimately
762
+ // still differ and are not this run's responsibility).
763
+ const residual = repoScope
764
+ ? []
765
+ : verifyReconciled(versions.map(({ version: v }) => ({ agent: agentId, version: v })), cwd);
766
+ if (!quiet && !json)
767
+ printResidual(residual, errLog);
703
768
  if (json) {
704
769
  emitJson({
705
- // Any version that refused a write makes the whole run not-ok (RUSH-2700).
706
- ok: versions.every(({ result }) => result.declined.length === 0),
770
+ // Any version that refused a write, or any residual drift after the
771
+ // reconcile, makes the whole run not-ok (RUSH-2700 + PHNX-3186).
772
+ ok: versions.every(({ result }) => result.declined.length === 0) && residual.length === 0,
707
773
  mode: 'agent-all',
708
774
  agent: agentId,
709
775
  repo: repoScope,
776
+ residualDrift: residual,
710
777
  ...healedPointers,
711
778
  versions: versions.map(({ version: v, result }) => ({
712
779
  version: v,
@@ -901,6 +968,11 @@ async function runSync(agentSpec, repoArg, opts) {
901
968
  return;
902
969
  }
903
970
  const result = syncResourcesToVersion(agentId, version, selection, { projectDir, cwd, force, allowExecSurfaces: !!opts.allowExecSurfaces });
971
+ // Post-reconcile verification (PHNX-3186). Only for a FULL reconcile
972
+ // (`!selection`): an interactive subset-selection deliberately touched only the
973
+ // picked kinds, so the rest legitimately still differs and is not this run's
974
+ // failure. This is the exact path `agents sync <agent>[@version]` takes.
975
+ const residual = selection ? [] : verifyReconciled([{ agent: agentId, version }], cwd);
904
976
  // Compile project-scope rules into the workspace itself so each agent's
905
977
  // native loader picks up cwd/<INSTRUCTIONS_FILE>. projectDir is the
906
978
  // .agents/ directory; the workspace root is its parent.
@@ -910,8 +982,11 @@ async function runSync(agentSpec, repoArg, opts) {
910
982
  projectCompile = compileRulesForProject(projectRoot);
911
983
  }
912
984
  if (json) {
985
+ const base = agentSyncJson(agentId, version, result);
913
986
  emitJson({
914
- ...agentSyncJson(agentId, version, result),
987
+ ...base,
988
+ ok: base.ok === true && residual.length === 0,
989
+ residualDrift: residual,
915
990
  ...healedPointers,
916
991
  projectCompile: projectCompile
917
992
  ? {
@@ -928,6 +1003,7 @@ async function runSync(agentSpec, repoArg, opts) {
928
1003
  return;
929
1004
  // ---------- 6. Detailed output ----------
930
1005
  printSyncDetail(result, agentId, version, cwd);
1006
+ printResidual(residual, errLog);
931
1007
  if (projectCompile?.compiled) {
932
1008
  const linkInfo = projectCompile.symlinks.length > 0
933
1009
  ? ` (+ ${projectCompile.symlinks.join(', ')})`
@@ -109,6 +109,13 @@ async function handleSync(opts) {
109
109
  parts.push(chalk.dim('nothing new'));
110
110
  }
111
111
  console.log(parts.join(chalk.dim(' · ')));
112
+ // The per-session data can upload cleanly while the aggregated console shard
113
+ // fails to refresh (e.g. the sessions DB is locked by a running app). That used
114
+ // to be silent, so the console sat stale with no signal here (PHNX-3401).
115
+ if (result.indexError) {
116
+ console.log(chalk.yellow(' ⚠ console index not refreshed') +
117
+ chalk.dim(` (${result.indexError}) — dashboard may be stale; it will retry next sync`));
118
+ }
112
119
  }
113
120
  async function handleStatus() {
114
121
  const ledger = readSyncLedger();
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
- * - `__usage-ingest`
18
+ * - `__usage-ingest` / `__usage-export`
19
19
  * - `__daemon-run`
20
20
  *
21
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
- * - `__usage-ingest`
18
+ * - `__usage-ingest` / `__usage-export`
19
19
  * - `__daemon-run`
20
20
  *
21
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
@@ -84,6 +84,11 @@ if (process.argv[2] === '__usage-ingest') {
84
84
  const { runUsageIngest } = await import('./lib/accounting/usage-ingest.js');
85
85
  process.exit(await runUsageIngest());
86
86
  }
87
+ if (process.argv[2] === '__usage-export') {
88
+ const { exportClaudeUsageCacheRows } = await import('./lib/accounting/usage.js');
89
+ process.stdout.write(JSON.stringify({ v: 1, rows: exportClaudeUsageCacheRows() }));
90
+ process.exit(0);
91
+ }
87
92
  if (process.argv[2] === '__daemon-run') {
88
93
  const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
89
94
  // RUSH-2418: the daemon is the one always-on process here, and it ran with no
@@ -63,11 +63,15 @@ export declare function unbindAccount(nameOrId: string, target: string): void;
63
63
  /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
64
  * the fleet-shared central bindings (PHNX-3315). */
65
65
  export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
66
+ export interface AccountSelection {
67
+ id: string;
68
+ source: 'explicit' | 'binding' | 'default';
69
+ }
66
70
  /** Explicit selection wins over a configured per-harness default. */
67
71
  export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
68
72
  useDefault?: boolean;
69
73
  target?: string;
70
- }): string | undefined;
74
+ }): AccountSelection | undefined;
71
75
  export interface AddAccountOptions {
72
76
  baseUrl?: string;
73
77
  }