@phnx-labs/agents-cli 1.22.75 → 1.22.77

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 (138) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +20 -8
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/config.js +27 -4
  11. package/dist/commands/cost.js +6 -4
  12. package/dist/commands/doctor.d.ts +6 -5
  13. package/dist/commands/doctor.js +32 -274
  14. package/dist/commands/exec.d.ts +2 -0
  15. package/dist/commands/exec.js +9 -2
  16. package/dist/commands/harness.d.ts +1 -0
  17. package/dist/commands/harness.js +11 -3
  18. package/dist/commands/hooks.js +7 -6
  19. package/dist/commands/mcp.js +7 -6
  20. package/dist/commands/memory.js +7 -7
  21. package/dist/commands/monitors.js +3 -2
  22. package/dist/commands/open.d.ts +25 -12
  23. package/dist/commands/open.js +24 -10
  24. package/dist/commands/permissions.js +7 -6
  25. package/dist/commands/plugins.js +21 -17
  26. package/dist/commands/route.js +33 -16
  27. package/dist/commands/rules.js +7 -12
  28. package/dist/commands/sessions-share.js +1 -1
  29. package/dist/commands/setup-watchdog.js +2 -2
  30. package/dist/commands/setup.js +22 -1
  31. package/dist/commands/share.js +26 -10
  32. package/dist/commands/skills.js +7 -6
  33. package/dist/commands/subagents.js +7 -6
  34. package/dist/commands/sync.js +81 -10
  35. package/dist/commands/view.js +4 -1
  36. package/dist/commands/watchdog.d.ts +1 -1
  37. package/dist/commands/watchdog.js +10 -10
  38. package/dist/commands/webhook.d.ts +4 -0
  39. package/dist/commands/webhook.js +22 -4
  40. package/dist/commands/workflows.js +7 -6
  41. package/dist/lib/accounting/rotate.d.ts +3 -1
  42. package/dist/lib/accounting/rotate.js +8 -4
  43. package/dist/lib/auth-health.d.ts +2 -0
  44. package/dist/lib/auth-health.js +2 -0
  45. package/dist/lib/browser/chrome.d.ts +21 -0
  46. package/dist/lib/browser/chrome.js +60 -3
  47. package/dist/lib/browser/drivers/local.d.ts +21 -0
  48. package/dist/lib/browser/drivers/local.js +102 -9
  49. package/dist/lib/browser/profiles.d.ts +29 -1
  50. package/dist/lib/browser/profiles.js +50 -1
  51. package/dist/lib/browser/registry.js +10 -3
  52. package/dist/lib/browser/types.d.ts +18 -0
  53. package/dist/lib/config-keys.d.ts +7 -2
  54. package/dist/lib/config-keys.js +17 -2
  55. package/dist/lib/daemon/daemon.js +8 -0
  56. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  57. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  58. package/dist/lib/daemon-services.d.ts +1 -1
  59. package/dist/lib/daemon-services.js +5 -0
  60. package/dist/lib/daemon-ticks.d.ts +2 -2
  61. package/dist/lib/daemon-ticks.js +2 -1
  62. package/dist/lib/deeplink/register.js +10 -9
  63. package/dist/lib/deeplink/url.d.ts +4 -4
  64. package/dist/lib/deeplink/url.js +4 -4
  65. package/dist/lib/device-config.js +25 -0
  66. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  67. package/dist/lib/devices/doctor-findings.js +14 -8
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/stats-cache.d.ts +4 -0
  70. package/dist/lib/devices/stats-cache.js +19 -0
  71. package/dist/lib/drift-sync.d.ts +3 -1
  72. package/dist/lib/drift-sync.js +16 -5
  73. package/dist/lib/exec.d.ts +2 -0
  74. package/dist/lib/exec.js +16 -1
  75. package/dist/lib/fleet-shared-state.d.ts +8 -0
  76. package/dist/lib/git.js +6 -0
  77. package/dist/lib/heal.d.ts +4 -3
  78. package/dist/lib/heal.js +5 -4
  79. package/dist/lib/hosts/ready.d.ts +1 -1
  80. package/dist/lib/hosts/ready.js +16 -4
  81. package/dist/lib/hosts/reconnect.js +4 -2
  82. package/dist/lib/identity/client.d.ts +6 -0
  83. package/dist/lib/identity/index.d.ts +16 -0
  84. package/dist/lib/identity/index.js +25 -1
  85. package/dist/lib/installations/migrate.d.ts +10 -0
  86. package/dist/lib/installations/migrate.js +18 -0
  87. package/dist/lib/profiles.d.ts +2 -0
  88. package/dist/lib/profiles.js +28 -9
  89. package/dist/lib/project-resources.d.ts +23 -0
  90. package/dist/lib/project-resources.js +94 -0
  91. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  92. package/dist/lib/reconcile-and-repair.js +267 -0
  93. package/dist/lib/routers.d.ts +12 -1
  94. package/dist/lib/routers.js +30 -1
  95. package/dist/lib/scheduling/routines.js +8 -2
  96. package/dist/lib/session/active.d.ts +13 -0
  97. package/dist/lib/session/db.d.ts +47 -7
  98. package/dist/lib/session/db.js +114 -12
  99. package/dist/lib/session/mirror.js +58 -0
  100. package/dist/lib/session/remote/watch.js +22 -2
  101. package/dist/lib/session/session-cache.d.ts +19 -0
  102. package/dist/lib/session/session-cache.js +46 -0
  103. package/dist/lib/session/types.d.ts +34 -0
  104. package/dist/lib/share/backend.d.ts +6 -4
  105. package/dist/lib/share/backend.js +10 -8
  106. package/dist/lib/share/config.d.ts +4 -3
  107. package/dist/lib/share/config.js +10 -1
  108. package/dist/lib/share/delete.d.ts +1 -1
  109. package/dist/lib/share/delete.js +1 -1
  110. package/dist/lib/share/html.d.ts +1 -1
  111. package/dist/lib/share/html.js +1 -1
  112. package/dist/lib/share/provision.d.ts +1 -1
  113. package/dist/lib/share/provision.js +2 -2
  114. package/dist/lib/share/publish.d.ts +23 -7
  115. package/dist/lib/share/publish.js +58 -12
  116. package/dist/lib/share/worker-template.js +221 -60
  117. package/dist/lib/startup/command-registry.js +2 -2
  118. package/dist/lib/state.d.ts +58 -1
  119. package/dist/lib/state.js +138 -16
  120. package/dist/lib/summarizer/config.d.ts +46 -0
  121. package/dist/lib/summarizer/config.js +83 -0
  122. package/dist/lib/summarizer/pass.d.ts +45 -0
  123. package/dist/lib/summarizer/pass.js +112 -0
  124. package/dist/lib/summarizer/summarize.d.ts +68 -0
  125. package/dist/lib/summarizer/summarize.js +120 -0
  126. package/dist/lib/teams/agents.d.ts +4 -3
  127. package/dist/lib/teams/agents.js +12 -4
  128. package/dist/lib/teams/scheduler.d.ts +4 -2
  129. package/dist/lib/teams/scheduler.js +6 -6
  130. package/dist/lib/tmux/session.d.ts +2 -0
  131. package/dist/lib/tmux/session.js +7 -1
  132. package/dist/lib/types.d.ts +20 -0
  133. package/dist/lib/verbs.d.ts +23 -0
  134. package/dist/lib/verbs.js +24 -0
  135. package/dist/lib/view-types.d.ts +4 -0
  136. package/dist/lib/watchdog/rotate.d.ts +1 -1
  137. package/dist/lib/watchdog/rotate.js +1 -1
  138. package/package.json +1 -1
@@ -319,6 +319,21 @@ export declare function getRuntimeStateDir(): string;
319
319
  export declare function getCompanionDir(): string;
320
320
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
321
321
  export declare function getBrowserRuntimeDir(): string;
322
+ /**
323
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
324
+ *
325
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
326
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
327
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
328
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
329
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
330
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
331
+ * survives quit+relaunch.
332
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
333
+ * ownership guard in the local driver compares the running instance's
334
+ * `--user-data-dir` against it to reject a foreign port-squatter.
335
+ */
336
+ export declare function getBrowserDurableDir(): string;
322
337
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
323
338
  export declare function getHelpersDir(): string;
324
339
  /**
@@ -394,6 +409,33 @@ export declare function getDeviceMetaPath(): string;
394
409
  */
395
410
  export declare function getVersionResourcesPath(): string;
396
411
  export declare function withMetaLock<T>(fn: () => T): T;
412
+ /**
413
+ * Commit the central `agents.yaml` in the user repo, synchronously, so a CLI
414
+ * config mutation never leaves the working tree dirty on that one shared-line
415
+ * file at rest.
416
+ *
417
+ * Why this exists: CLI config commands rewrite the fleet-shared central
418
+ * agents.yaml as a plain file write. Left uncommitted, the tree is dirty on
419
+ * agents.yaml between the write and the daemon's next 15-min publish tick — and
420
+ * a peer's incoming publish commit (which also touches agents.yaml) then trips
421
+ * `dirtyTreeRefusal` ("incoming changes touch uncommitted paths: agents.yaml"),
422
+ * wedging `agents repo pull` fleet-wide (PHNX-3968). Committing the central edit
423
+ * in the same command that made it closes that window: agents.yaml is clean at
424
+ * rest, so nothing incoming can collide with it. Called AFTER the meta lock
425
+ * releases (see {@link commitCentralConfigAfterWrite}) so the git subprocesses
426
+ * never run inside the short, non-heartbeated lockfile window; the user repo
427
+ * already pushes.
428
+ *
429
+ * Scoped tightly: only called when the central bytes actually changed, and never
430
+ * from the daemon (see {@link isDaemonProcess}). A commit failure fails open —
431
+ * a concurrent daemon holding `index.lock`, a mid-rebase repo — leaving
432
+ * agents.yaml dirty; the next successful central write commits it, and until
433
+ * then a pull that would collide refuses rather than losing data. A config
434
+ * command must never fail because git hiccuped.
435
+ *
436
+ * Returns whether a commit was created. Exported for the real-repo tests.
437
+ */
438
+ export declare function commitCentralConfig(userDir: string): boolean;
397
439
  /**
398
440
  * True when the top-level `~/.agents/agents.yaml` carries a metadata header that
399
441
  * is NOT the canonical {@link META_HEADER} — the P1 frozen-header case a box only
@@ -416,8 +458,13 @@ export declare function readTopLevelUserMeta(): Record<string, unknown> | null;
416
458
  * writer that needs to read fresh state, decide, and commit within a SINGLE lock
417
459
  * acquisition (e.g. browser tombstone eviction) can do so without the
418
460
  * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
461
+ *
462
+ * Returns whether the central `agents.yaml` bytes actually changed, so the
463
+ * caller can commit it once — {@link commitCentralConfig} — AFTER releasing the
464
+ * meta lock (the git subprocesses must not run inside the short, non-heartbeated
465
+ * lockfile window). This function never commits.
419
466
  */
420
- export declare function writeMetaUnlocked(meta: Meta): void;
467
+ export declare function writeMetaUnlocked(meta: Meta): boolean;
421
468
  /**
422
469
  * Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
423
470
  *
@@ -433,6 +480,16 @@ export declare function readMeta(): Meta;
433
480
  export declare function writeMeta(meta: Meta): void;
434
481
  /** Update agents.yaml under lock and return the new state. */
435
482
  export declare function updateMeta(updates: Partial<Meta> | ((meta: Meta) => Meta)): Meta;
483
+ /**
484
+ * Commit-on-write, invoked AFTER {@link withMetaLock} releases so the git
485
+ * subprocesses never run inside the short, non-heartbeated meta-lock window.
486
+ * A CLI command that actually moved the fleet-shared central agents.yaml commits
487
+ * it so the tree is never left dirty on that file at rest — the window that
488
+ * trips `dirtyTreeRefusal` and wedges pulls fleet-wide (PHNX-3968). Gated on a
489
+ * real byte change and never run in the daemon, whose publish tick owns central
490
+ * commits. See {@link commitCentralConfig}.
491
+ */
492
+ export declare function commitCentralConfigAfterWrite(centralChanged: boolean): void;
436
493
  /** Derive a filesystem-safe local clone path for a package source URL. */
437
494
  export declare function getPackageLocalPath(source: string): string;
438
495
  import type { AgentId, ResourceType, VersionResources, ResourcePattern } from './types.js';
package/dist/lib/state.js CHANGED
@@ -642,6 +642,21 @@ export function getRuntimeStateDir() { return process.env.AGENTS_STATE_DIR ?? RU
642
642
  export function getCompanionDir() { return COMPANION_CACHE_DIR; }
643
643
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
644
644
  export function getBrowserRuntimeDir() { return BROWSER_RUNTIME_DIR; }
645
+ /**
646
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
647
+ *
648
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
649
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
650
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
651
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
652
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
653
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
654
+ * survives quit+relaunch.
655
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
656
+ * ownership guard in the local driver compares the running instance's
657
+ * `--user-data-dir` against it to reject a foreign port-squatter.
658
+ */
659
+ export function getBrowserDurableDir() { return path.join(HISTORY_DIR, 'browser-profiles'); }
645
660
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
646
661
  export function getHelpersDir() { return HELPERS_DIR; }
647
662
  /**
@@ -835,7 +850,9 @@ export function withMetaLock(fn) {
835
850
  });
836
851
  }
837
852
  /** Atomic write only when the on-disk content differs — avoids needless mtime
838
- * bumps (which would thrash the meta cache) on no-op field routing. */
853
+ * bumps (which would thrash the meta cache) on no-op field routing. Returns
854
+ * whether it actually wrote, so a caller can react only to a real change (e.g.
855
+ * commit the central agents.yaml exactly when its bytes moved). */
839
856
  function writeIfChanged(filePath, content) {
840
857
  let current = null;
841
858
  try {
@@ -843,8 +860,74 @@ function writeIfChanged(filePath, content) {
843
860
  }
844
861
  catch { /* absent */ }
845
862
  if (current === content)
846
- return;
863
+ return false;
847
864
  atomicWriteFileSync(filePath, content);
865
+ return true;
866
+ }
867
+ /**
868
+ * True in the always-on daemon process (launched as `agents __daemon-run`,
869
+ * see cli/src/index.ts). The daemon owns central git commits through
870
+ * fleet-shared-repo-sync's publish tick, so a central write from inside the
871
+ * daemon must NOT also commit here — that would race the publisher's own
872
+ * add/commit/rebase/push on the same index. Every ordinary CLI invocation
873
+ * returns false and commits its own central edit synchronously.
874
+ */
875
+ function isDaemonProcess() {
876
+ return process.argv[2] === '__daemon-run';
877
+ }
878
+ /**
879
+ * Commit the central `agents.yaml` in the user repo, synchronously, so a CLI
880
+ * config mutation never leaves the working tree dirty on that one shared-line
881
+ * file at rest.
882
+ *
883
+ * Why this exists: CLI config commands rewrite the fleet-shared central
884
+ * agents.yaml as a plain file write. Left uncommitted, the tree is dirty on
885
+ * agents.yaml between the write and the daemon's next 15-min publish tick — and
886
+ * a peer's incoming publish commit (which also touches agents.yaml) then trips
887
+ * `dirtyTreeRefusal` ("incoming changes touch uncommitted paths: agents.yaml"),
888
+ * wedging `agents repo pull` fleet-wide (PHNX-3968). Committing the central edit
889
+ * in the same command that made it closes that window: agents.yaml is clean at
890
+ * rest, so nothing incoming can collide with it. Called AFTER the meta lock
891
+ * releases (see {@link commitCentralConfigAfterWrite}) so the git subprocesses
892
+ * never run inside the short, non-heartbeated lockfile window; the user repo
893
+ * already pushes.
894
+ *
895
+ * Scoped tightly: only called when the central bytes actually changed, and never
896
+ * from the daemon (see {@link isDaemonProcess}). A commit failure fails open —
897
+ * a concurrent daemon holding `index.lock`, a mid-rebase repo — leaving
898
+ * agents.yaml dirty; the next successful central write commits it, and until
899
+ * then a pull that would collide refuses rather than losing data. A config
900
+ * command must never fail because git hiccuped.
901
+ *
902
+ * Returns whether a commit was created. Exported for the real-repo tests.
903
+ */
904
+ export function commitCentralConfig(userDir) {
905
+ const rel = 'agents.yaml';
906
+ try {
907
+ execFileSync('git', ['-C', userDir, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
908
+ }
909
+ catch {
910
+ return false; // plain ~/.agents with no git repo — leave it a loose write.
911
+ }
912
+ try {
913
+ execFileSync('git', ['-C', userDir, 'add', '--', rel], { stdio: 'ignore' });
914
+ // Nothing staged for agents.yaml (its bytes matched HEAD after all) → no
915
+ // empty commit. `diff --cached --quiet` exits 0 when the index equals HEAD
916
+ // for this path, 1 when it differs.
917
+ try {
918
+ execFileSync('git', ['-C', userDir, 'diff', '--cached', '--quiet', '--', rel], { stdio: 'ignore' });
919
+ return false;
920
+ }
921
+ catch { /* exit 1 → staged changes present, commit them */ }
922
+ // Pathspec-scoped commit: records ONLY agents.yaml even if other paths are
923
+ // staged, so a config write never sweeps unrelated staged work into its commit.
924
+ execFileSync('git', ['-C', userDir, '-c', 'commit.gpgsign=false', 'commit', '--no-verify',
925
+ '-m', 'chore(config): update agents.yaml', '--', rel], { stdio: 'ignore' });
926
+ return true;
927
+ }
928
+ catch {
929
+ return false; // fail open — see the doc comment.
930
+ }
848
931
  }
849
932
  /**
850
933
  * Partition the in-memory Meta across four files by sync-domain:
@@ -1134,8 +1217,20 @@ function serializeCentral(central) {
1134
1217
  * writer that needs to read fresh state, decide, and commit within a SINGLE lock
1135
1218
  * acquisition (e.g. browser tombstone eviction) can do so without the
1136
1219
  * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
1220
+ *
1221
+ * Returns whether the central `agents.yaml` bytes actually changed, so the
1222
+ * caller can commit it once — {@link commitCentralConfig} — AFTER releasing the
1223
+ * meta lock (the git subprocesses must not run inside the short, non-heartbeated
1224
+ * lockfile window). This function never commits.
1137
1225
  */
1138
1226
  export function writeMetaUnlocked(meta) {
1227
+ const writesDeviceRoutines = Object.prototype.hasOwnProperty.call(meta, 'deviceRoutines');
1228
+ const writesDeviceConfig = Object.prototype.hasOwnProperty.call(meta, 'deviceConfig');
1229
+ const writesDeviceBrowser = Object.prototype.hasOwnProperty.call(meta, 'deviceBrowser');
1230
+ const writesDeviceFleet = Object.prototype.hasOwnProperty.call(meta, 'deviceFleet');
1231
+ const writesDeviceHosts = Object.prototype.hasOwnProperty.call(meta, 'deviceHosts');
1232
+ const writesDeviceAccounts = Object.prototype.hasOwnProperty.call(meta, 'deviceAccounts');
1233
+ const writesProjectRoot = Object.prototype.hasOwnProperty.call(meta, 'projectRoot');
1139
1234
  // INVARIANT: every key destructured here must also be in BESPOKE_DEVICE_KEYS (and
1140
1235
  // vice versa) — a bespoke device key that is classified but NOT pulled out here
1141
1236
  // would fall into `central`, and the generic router skips it (BESPOKE_DEVICE_KEY_SET),
@@ -1191,17 +1286,17 @@ export function writeMetaUnlocked(meta) {
1191
1286
  delete doc.isolatedAgents;
1192
1287
  if (Array.isArray(deviceRoutines))
1193
1288
  doc.routines = deviceRoutines;
1194
- else
1289
+ else if (writesDeviceRoutines)
1195
1290
  delete doc.routines;
1196
1291
  const hasDeviceConfig = !!deviceConfig && Object.keys(deviceConfig).length > 0;
1197
1292
  if (hasDeviceConfig)
1198
1293
  doc.config = deviceConfig;
1199
- else
1294
+ else if (writesDeviceConfig)
1200
1295
  delete doc.config;
1201
1296
  const hasDeviceBrowser = !!deviceBrowser && Object.keys(deviceBrowser).length > 0;
1202
1297
  if (hasDeviceBrowser)
1203
1298
  doc.browser = deviceBrowser;
1204
- else
1299
+ else if (writesDeviceBrowser)
1205
1300
  delete doc.browser;
1206
1301
  // PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
1207
1302
  // slice; the effective fleet view is unioned across every device doc at read
@@ -1219,12 +1314,12 @@ export function writeMetaUnlocked(meta) {
1219
1314
  df.ignored = fleetIgnored;
1220
1315
  doc.fleet = df;
1221
1316
  }
1222
- else
1317
+ else if (writesDeviceFleet)
1223
1318
  delete doc.fleet;
1224
1319
  const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
1225
1320
  if (hasDeviceHosts)
1226
1321
  doc.hosts = deviceHosts;
1227
- else
1322
+ else if (writesDeviceHosts)
1228
1323
  delete doc.hosts;
1229
1324
  const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
1230
1325
  ? deviceAccounts.native : undefined;
@@ -1238,12 +1333,12 @@ export function writeMetaUnlocked(meta) {
1238
1333
  da.bindings = accountsBindings;
1239
1334
  doc.accounts = da;
1240
1335
  }
1241
- else
1336
+ else if (writesDeviceAccounts)
1242
1337
  delete doc.accounts;
1243
1338
  const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
1244
1339
  if (hasProjectRoot)
1245
1340
  doc.projectRoot = projectRoot;
1246
- else
1341
+ else if (writesProjectRoot)
1247
1342
  delete doc.projectRoot;
1248
1343
  // Generic device-scoped keys (PHNX-3315): any key left in `central` that this
1249
1344
  // version classifies as device but does NOT bespoke-route round-trips through
@@ -1284,8 +1379,9 @@ export function writeMetaUnlocked(meta) {
1284
1379
  fs.mkdirSync(path.dirname(vrPath), { recursive: true });
1285
1380
  writeIfChanged(vrPath, JSON.stringify(versions, null, 2) + '\n');
1286
1381
  }
1287
- writeIfChanged(META_FILE, serializeCentral(central));
1382
+ const centralChanged = writeIfChanged(META_FILE, serializeCentral(central));
1288
1383
  metaCache = null;
1384
+ return centralChanged;
1289
1385
  }
1290
1386
  /**
1291
1387
  * Overlay this machine's local state onto a central-portable Meta:
@@ -1471,7 +1567,15 @@ export function readMeta() {
1471
1567
  if (parsed.registries) {
1472
1568
  meta.registries = parsed.registries;
1473
1569
  }
1474
- writeMeta(meta);
1570
+ // Lock-safe, commit-free write: withMetaLock is reentrant (see
1571
+ // metaLockDepth), so this writes under the lock when called standalone and
1572
+ // is a no-op re-entry when readMeta runs inside updateMeta/writeMeta's held
1573
+ // lock — and writeMetaUnlocked never spawns git. Calling the PUBLIC
1574
+ // writeMeta here would run commit-on-write's git subprocess inside a held,
1575
+ // non-heartbeated lock. This one-shot legacy migration needs no synchronous
1576
+ // commit: it self-heals on the daemon's next publish tick or the next real
1577
+ // CLI central write.
1578
+ withMetaLock(() => writeMetaUnlocked(meta));
1475
1579
  try {
1476
1580
  fs.unlinkSync(oldMetaFile);
1477
1581
  }
@@ -1525,18 +1629,36 @@ export function readMeta() {
1525
1629
  }
1526
1630
  /** Serialize and write agents.yaml to the user repo, invalidating the in-memory cache. */
1527
1631
  export function writeMeta(meta) {
1528
- withMetaLock(() => writeMetaUnlocked(meta));
1632
+ const centralChanged = withMetaLock(() => writeMetaUnlocked(meta));
1633
+ commitCentralConfigAfterWrite(centralChanged);
1529
1634
  }
1530
1635
  /** Update agents.yaml under lock and return the new state. */
1531
1636
  export function updateMeta(updates) {
1532
- return withMetaLock(() => {
1637
+ let centralChanged = false;
1638
+ const newMeta = withMetaLock(() => {
1533
1639
  const meta = readMeta();
1534
- const newMeta = typeof updates === 'function'
1640
+ const nm = typeof updates === 'function'
1535
1641
  ? updates(meta)
1536
1642
  : { ...meta, ...updates };
1537
- writeMetaUnlocked(newMeta);
1538
- return newMeta;
1643
+ centralChanged = writeMetaUnlocked(nm);
1644
+ return nm;
1539
1645
  });
1646
+ commitCentralConfigAfterWrite(centralChanged);
1647
+ return newMeta;
1648
+ }
1649
+ /**
1650
+ * Commit-on-write, invoked AFTER {@link withMetaLock} releases so the git
1651
+ * subprocesses never run inside the short, non-heartbeated meta-lock window.
1652
+ * A CLI command that actually moved the fleet-shared central agents.yaml commits
1653
+ * it so the tree is never left dirty on that file at rest — the window that
1654
+ * trips `dirtyTreeRefusal` and wedges pulls fleet-wide (PHNX-3968). Gated on a
1655
+ * real byte change and never run in the daemon, whose publish tick owns central
1656
+ * commits. See {@link commitCentralConfig}.
1657
+ */
1658
+ export function commitCentralConfigAfterWrite(centralChanged) {
1659
+ if (centralChanged && !isDaemonProcess()) {
1660
+ commitCentralConfig(USER_AGENTS_DIR);
1661
+ }
1540
1662
  }
1541
1663
  /** Derive a filesystem-safe local clone path for a package source URL. */
1542
1664
  export function getPackageLocalPath(source) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session-summarizer configuration (PHNX-3939).
3
+ *
4
+ * Resolves the three knobs behind the daemon summarizer — enabled / base URL /
5
+ * model — from `agents config` (`summarizer.*`, user-scope in the central
6
+ * agents.yaml) with a per-process env override
7
+ * (`AGENTS_SUMMARIZER_ENABLED` / `AGENTS_SUMMARIZER_BASEURL` /
8
+ * `AGENTS_SUMMARIZER_MODEL`). Off by default: with no config and no env, the
9
+ * summarizer is disabled and makes zero model calls.
10
+ *
11
+ * `isSummarizerReady()` is memoized on a short TTL because the display merge
12
+ * (the watch-stream projections) calls it once per session row — a fresh
13
+ * agents.yaml read per row would defeat the "blazing fast" requirement.
14
+ */
15
+ export interface SummarizerConfig {
16
+ enabled: boolean;
17
+ /** Anthropic-wire base URL (Ollama/vLLM/LiteLLM), or undefined when unconfigured. */
18
+ baseUrl?: string;
19
+ /** Model id to request, or undefined when unconfigured. */
20
+ model?: string;
21
+ }
22
+ /**
23
+ * Resolve the full summarizer config. Env overrides the stored config key by key;
24
+ * an unset env var falls through to `agents config`, then to the built-in
25
+ * default (disabled). Never throws — a missing/corrupt config reads as unset.
26
+ */
27
+ export declare function resolveSummarizerConfig(env?: NodeJS.ProcessEnv): SummarizerConfig;
28
+ /**
29
+ * True only when the summarizer is enabled AND has a base URL + model to call.
30
+ * A configuration that is `enabled` but missing an endpoint cannot produce a
31
+ * summary, so it is treated as unconfigured (the service no-ops, the merge marks
32
+ * `skipped`) rather than erroring on every tick.
33
+ */
34
+ export declare function isSummarizerRunnable(config: SummarizerConfig): boolean;
35
+ /**
36
+ * Memoized "will a summary actually be produced?" check for the hot merge path.
37
+ * Reflects {@link isSummarizerRunnable} — enabled AND a base URL AND a model —
38
+ * NOT just `enabled`, because an enabled-but-unconfigured summarizer computes
39
+ * nothing, so a row with no cached summary must read `skipped`, not a `pending`
40
+ * that never resolves (the exact case: `summarizer.enabled on` set before the
41
+ * endpoint). TTL keeps a config change visible within a few seconds without a
42
+ * per-row agents.yaml read.
43
+ */
44
+ export declare function isSummarizerReady(nowMs?: number): boolean;
45
+ /** Test seam: drop the memoized ready flag so the next read re-resolves. */
46
+ export declare function resetSummarizerReadyCacheForTest(): void;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Session-summarizer configuration (PHNX-3939).
3
+ *
4
+ * Resolves the three knobs behind the daemon summarizer — enabled / base URL /
5
+ * model — from `agents config` (`summarizer.*`, user-scope in the central
6
+ * agents.yaml) with a per-process env override
7
+ * (`AGENTS_SUMMARIZER_ENABLED` / `AGENTS_SUMMARIZER_BASEURL` /
8
+ * `AGENTS_SUMMARIZER_MODEL`). Off by default: with no config and no env, the
9
+ * summarizer is disabled and makes zero model calls.
10
+ *
11
+ * `isSummarizerReady()` is memoized on a short TTL because the display merge
12
+ * (the watch-stream projections) calls it once per session row — a fresh
13
+ * agents.yaml read per row would defeat the "blazing fast" requirement.
14
+ */
15
+ import { getConfigValue } from '../device-config.js';
16
+ /** Parse a boolean-ish env value; undefined when the var is unset. */
17
+ function envBool(raw) {
18
+ if (raw === undefined)
19
+ return undefined;
20
+ const v = raw.trim().toLowerCase();
21
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes')
22
+ return true;
23
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no' || v === '')
24
+ return false;
25
+ return undefined;
26
+ }
27
+ function envString(raw) {
28
+ const v = raw?.trim();
29
+ return v ? v : undefined;
30
+ }
31
+ /**
32
+ * Resolve the full summarizer config. Env overrides the stored config key by key;
33
+ * an unset env var falls through to `agents config`, then to the built-in
34
+ * default (disabled). Never throws — a missing/corrupt config reads as unset.
35
+ */
36
+ export function resolveSummarizerConfig(env = process.env) {
37
+ let storedEnabled;
38
+ let storedBaseUrl;
39
+ let storedModel;
40
+ try {
41
+ storedEnabled = getConfigValue('summarizer.enabled').value;
42
+ storedBaseUrl = getConfigValue('summarizer.baseUrl').value;
43
+ storedModel = getConfigValue('summarizer.model').value;
44
+ }
45
+ catch {
46
+ // A missing/corrupt config store must not break the read path — treat as unset.
47
+ }
48
+ const enabled = envBool(env.AGENTS_SUMMARIZER_ENABLED) ?? storedEnabled ?? false;
49
+ const baseUrl = envString(env.AGENTS_SUMMARIZER_BASEURL) ?? envString(storedBaseUrl);
50
+ const model = envString(env.AGENTS_SUMMARIZER_MODEL) ?? envString(storedModel);
51
+ return { enabled, baseUrl, model };
52
+ }
53
+ /**
54
+ * True only when the summarizer is enabled AND has a base URL + model to call.
55
+ * A configuration that is `enabled` but missing an endpoint cannot produce a
56
+ * summary, so it is treated as unconfigured (the service no-ops, the merge marks
57
+ * `skipped`) rather than erroring on every tick.
58
+ */
59
+ export function isSummarizerRunnable(config) {
60
+ return config.enabled && Boolean(config.baseUrl) && Boolean(config.model);
61
+ }
62
+ let cachedReady = null;
63
+ const READY_TTL_MS = 3_000;
64
+ /**
65
+ * Memoized "will a summary actually be produced?" check for the hot merge path.
66
+ * Reflects {@link isSummarizerRunnable} — enabled AND a base URL AND a model —
67
+ * NOT just `enabled`, because an enabled-but-unconfigured summarizer computes
68
+ * nothing, so a row with no cached summary must read `skipped`, not a `pending`
69
+ * that never resolves (the exact case: `summarizer.enabled on` set before the
70
+ * endpoint). TTL keeps a config change visible within a few seconds without a
71
+ * per-row agents.yaml read.
72
+ */
73
+ export function isSummarizerReady(nowMs = Date.now()) {
74
+ if (cachedReady && nowMs - cachedReady.at < READY_TTL_MS)
75
+ return cachedReady.value;
76
+ const value = isSummarizerRunnable(resolveSummarizerConfig());
77
+ cachedReady = { at: nowMs, value };
78
+ return value;
79
+ }
80
+ /** Test seam: drop the memoized ready flag so the next read re-resolves. */
81
+ export function resetSummarizerReadyCacheForTest() {
82
+ cachedReady = null;
83
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One session-summarizer pass (PHNX-3939) — the work the background
3
+ * SessionSummarizerService does per tick, factored out so it is testable with an
4
+ * injected model stub and no daemon scaffolding.
5
+ *
6
+ * Off the request path by construction: it reads THIS box's already-published
7
+ * live sessions (the warm cache the session-state service writes — never a fresh
8
+ * gather) and, per session, reuses the transcript-keyed `session_summaries` row
9
+ * until the transcript bytes change, so an unchanged session costs one indexed
10
+ * read and zero model calls. When the summarizer is disabled or has no endpoint,
11
+ * the pass does nothing at all.
12
+ */
13
+ import type { ActiveSession } from '../session/active.js';
14
+ import { type SummarizerConfig } from './config.js';
15
+ import { summarize as defaultSummarize } from './summarize.js';
16
+ /** Max sessions summarized per tick — a debounce ceiling on local-model calls. */
17
+ export declare const SUMMARIZER_MAX_PER_TICK = 8;
18
+ export interface SummarizerPassOptions {
19
+ now?: number;
20
+ config?: SummarizerConfig;
21
+ /** Live sessions to consider; default = this box's warm local-session cache. */
22
+ sessions?: ActiveSession[];
23
+ /** Injectable model call (tests stub the endpoint here). */
24
+ summarizeImpl?: typeof defaultSummarize;
25
+ signal?: AbortSignal;
26
+ maxPerTick?: number;
27
+ /** Injectable stat for tests; defaults to fs.statSync. */
28
+ statFile?: (p: string) => {
29
+ mtimeMs: number;
30
+ size: number;
31
+ };
32
+ /** Skip the reader-recency gate (tests pass sessions directly). */
33
+ requireReader?: boolean;
34
+ }
35
+ export interface SummarizerPassResult {
36
+ disabled: boolean;
37
+ computed: number;
38
+ reused: number;
39
+ skipped: number;
40
+ }
41
+ /**
42
+ * Run one pass. Returns per-outcome counts. Never throws — a per-session failure
43
+ * is isolated so one bad transcript can't stall the whole tick.
44
+ */
45
+ export declare function runSummarizerPass(opts?: SummarizerPassOptions): Promise<SummarizerPassResult>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * One session-summarizer pass (PHNX-3939) — the work the background
3
+ * SessionSummarizerService does per tick, factored out so it is testable with an
4
+ * injected model stub and no daemon scaffolding.
5
+ *
6
+ * Off the request path by construction: it reads THIS box's already-published
7
+ * live sessions (the warm cache the session-state service writes — never a fresh
8
+ * gather) and, per session, reuses the transcript-keyed `session_summaries` row
9
+ * until the transcript bytes change, so an unchanged session costs one indexed
10
+ * read and zero model calls. When the summarizer is disabled or has no endpoint,
11
+ * the pass does nothing at all.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import { readSessionSummary, readSessionSummaryAny, writeSessionSummary, } from '../session/db.js';
15
+ import { isActiveSessionsJournalReaderRecent, readActiveSessionsCache, } from '../session/session-cache.js';
16
+ import { resolveSummarizerConfig, isSummarizerRunnable } from './config.js';
17
+ import { summarize as defaultSummarize } from './summarize.js';
18
+ /** Max sessions summarized per tick — a debounce ceiling on local-model calls. */
19
+ export const SUMMARIZER_MAX_PER_TICK = 8;
20
+ /**
21
+ * Merge model checkpoint TEXTS with any prior stored checkpoints so a line that
22
+ * already existed keeps its original `at`, and only genuinely new lines get
23
+ * stamped `now`. Order follows the model output (newest last).
24
+ */
25
+ function stampCheckpoints(texts, prior, nowIso) {
26
+ const priorAt = new Map((prior ?? []).map((c) => [c.text, c.at]));
27
+ return texts.map((text) => ({ text, at: priorAt.get(text) ?? nowIso }));
28
+ }
29
+ /**
30
+ * Run one pass. Returns per-outcome counts. Never throws — a per-session failure
31
+ * is isolated so one bad transcript can't stall the whole tick.
32
+ */
33
+ export async function runSummarizerPass(opts = {}) {
34
+ const now = opts.now ?? Date.now();
35
+ const config = opts.config ?? resolveSummarizerConfig();
36
+ const result = { disabled: false, computed: 0, reused: 0, skipped: 0 };
37
+ if (!isSummarizerRunnable(config)) {
38
+ result.disabled = true;
39
+ return result;
40
+ }
41
+ let sessions = opts.sessions;
42
+ if (!sessions) {
43
+ // Reader-gated like the active-sessions warm tick: no watcher, no work.
44
+ if (opts.requireReader !== false && !isActiveSessionsJournalReaderRecent(now))
45
+ return result;
46
+ sessions = readActiveSessionsCache('local')?.sessions ?? [];
47
+ }
48
+ const stat = opts.statFile ?? ((p) => {
49
+ const s = fs.statSync(p);
50
+ return { mtimeMs: s.mtimeMs, size: s.size };
51
+ });
52
+ const runSummarize = opts.summarizeImpl ?? defaultSummarize;
53
+ const maxPerTick = opts.maxPerTick ?? SUMMARIZER_MAX_PER_TICK;
54
+ const nowIso = new Date(now).toISOString();
55
+ let budget = maxPerTick;
56
+ for (const s of sessions) {
57
+ if (opts.signal?.aborted)
58
+ break;
59
+ if (budget <= 0)
60
+ break;
61
+ const id = s.sessionId;
62
+ const file = s.sessionFile;
63
+ if (!id || !file)
64
+ continue;
65
+ let stamp;
66
+ try {
67
+ const st = stat(file);
68
+ stamp = { fileMtimeMs: Math.round(st.mtimeMs), fileSize: st.size };
69
+ }
70
+ catch {
71
+ continue; // transcript unreadable this tick — try again next time
72
+ }
73
+ // Cached for these exact bytes → never recompute (the core "blazing fast" rule).
74
+ if (readSessionSummary(id, stamp)) {
75
+ result.reused++;
76
+ continue;
77
+ }
78
+ budget--;
79
+ const prompt = (s.firstUserMessage ?? s.topic ?? '').trim();
80
+ const prior = readSessionSummaryAny(id);
81
+ if (!prompt) {
82
+ // No extractable intent yet — cache a skip against these bytes so we don't
83
+ // re-attempt every tick; a later transcript write (new bytes) retries.
84
+ writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
85
+ result.skipped++;
86
+ continue;
87
+ }
88
+ let computed;
89
+ try {
90
+ computed = await runSummarize(prompt, { todos: s.todos, plan: s.plan, phase: s.phase }, { baseUrl: config.baseUrl, model: config.model, ...(opts.signal ? { signal: opts.signal } : {}) });
91
+ }
92
+ catch {
93
+ computed = undefined;
94
+ }
95
+ if (!computed) {
96
+ writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
97
+ result.skipped++;
98
+ continue;
99
+ }
100
+ const entry = {
101
+ // Goal is computed ONCE at first sight — a prior goal is kept across
102
+ // progress deltas so it stays stable while checkpoints/checklist refresh.
103
+ goal: prior?.goal ?? computed.goal,
104
+ checkpoints: stampCheckpoints(computed.checkpoints, prior?.checkpoints, nowIso),
105
+ summaryChecklist: computed.checklist,
106
+ summaryState: 'ready',
107
+ };
108
+ writeSessionSummary({ id, ...stamp, summary: entry });
109
+ result.computed++;
110
+ }
111
+ return result;
112
+ }