@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -19,7 +19,8 @@
19
19
  export function configureRootCommand(program, name, version) {
20
20
  return program
21
21
  .name(name)
22
- .description('Environment manager for AI agents')
22
+ .description('Install, configure, run, and dispatch AI coding agents from one place.\n' +
23
+ 'Works with Claude, Codex, Antigravity, Cursor, OpenCode, OpenClaw, and Droid.')
23
24
  .version(version)
24
25
  .option('--verbose', 'Show startup self-heal details on stderr')
25
26
  .helpOption('-h, --help', 'Show help')
@@ -391,6 +391,30 @@ export declare function getDeviceMetaPath(): string;
391
391
  */
392
392
  export declare function getVersionResourcesPath(): string;
393
393
  export declare function withMetaLock<T>(fn: () => T): T;
394
+ /**
395
+ * True when the top-level `~/.agents/agents.yaml` carries a metadata header that
396
+ * is NOT the canonical {@link META_HEADER} — the P1 frozen-header case a box only
397
+ * heals on its next central write (serializeCentral). An absent or headerless
398
+ * file is NOT stale (a headerless central file is deliberately left alone).
399
+ * Surfaced as config drift by `agents sync status` (PHNX-3315).
400
+ */
401
+ export declare function hasStaleMetaHeader(): boolean;
402
+ /**
403
+ * Parse the top-level user `agents.yaml` (this box's synced central file) WITHOUT
404
+ * the system-repo merge, machine-local overlay, or cache — the raw on-disk central
405
+ * map, or null when the file is absent/unparseable. Used by config-drift detection
406
+ * to see the central blocks that should have folded into the device doc, without
407
+ * mis-attributing a system-repo default as this box's own leak (PHNX-3315).
408
+ */
409
+ export declare function readTopLevelUserMeta(): Record<string, unknown> | null;
410
+ /**
411
+ * Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
412
+ * lock — the caller must already hold it via {@link withMetaLock}. Exported so a
413
+ * writer that needs to read fresh state, decide, and commit within a SINGLE lock
414
+ * acquisition (e.g. browser tombstone eviction) can do so without the
415
+ * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
416
+ */
417
+ export declare function writeMetaUnlocked(meta: Meta): void;
394
418
  /**
395
419
  * Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
396
420
  *
package/dist/lib/state.js CHANGED
@@ -858,49 +858,99 @@ function writeIfChanged(filePath, content) {
858
858
  * `agents:` / `versions:` are not written (no empty committed files).
859
859
  */
860
860
  /**
861
- * Every `Meta` key, classified as `central` (synced via agents.yaml) or `device`
862
- * (routed to the per-machine device file by {@link writeMetaUnlocked}). The
863
- * `Record<keyof Meta, …>` type makes this EXHAUSTIVE: adding a field to `Meta`
864
- * without classifying it here is a compile error. That guarantee is what lets
865
- * `serializeCentral` delete only keys THIS version models — a key a newer CLI
866
- * version added (absent from this map) is preserved verbatim instead of being
867
- * dropped and synced fleet-wide as data loss. This is the fix for the recurring
868
- * agents.yaml key-loss (beta flags, `notify.owner`, and `feed:` all vanished
869
- * this way when an older-versioned CLI on the fleet rewrote the file).
861
+ * Fleet-shared (`central`) Meta keys — the OPT-IN allowlist. A key listed here is
862
+ * written to the synced `~/.agents/agents.yaml`. Device-scope is the DEFAULT:
863
+ * {@link metaKeyScope} returns `'device'` for every Meta key NOT listed here, so a
864
+ * newly-added key can never SILENTLY churn the shared file the way a
865
+ * central-by-default would — the very trap behind the recurring agents.yaml churn.
866
+ * Making a key fleet-shared is now a deliberate edit HERE, not the path of least
867
+ * resistance (PHNX-3315).
870
868
  */
871
- const META_KEY_SCOPE = {
872
- // Device-local — writeMetaUnlocked destructures these out; never in central.
873
- agents: 'device',
874
- isolatedAgents: 'device',
875
- versions: 'device',
876
- deviceRoutines: 'device',
877
- deviceConfig: 'device',
878
- deviceBrowser: 'device',
879
- // Central — synced via agents.yaml.
880
- accounts: 'central',
881
- run: 'central',
882
- model: 'central',
883
- watchdog: 'central',
884
- lease: 'central',
885
- secrets: 'central',
886
- budget: 'central',
887
- feed: 'central',
888
- beta: 'central',
889
- registries: 'central',
890
- profiles: 'central',
891
- source: 'central',
892
- projectRoot: 'device',
893
- extraRepos: 'central',
894
- brands: 'central',
895
- actors: 'central',
896
- seededPresets: 'central',
897
- hooks: 'central',
898
- config: 'central',
899
- hosts: 'central',
900
- fleet: 'central',
901
- share: 'central',
902
- notify: 'central',
903
- };
869
+ const CENTRAL_META_KEYS = [
870
+ 'accounts',
871
+ 'run',
872
+ 'model',
873
+ 'watchdog',
874
+ 'lease',
875
+ 'secrets',
876
+ 'budget',
877
+ 'feed',
878
+ 'beta',
879
+ 'registries',
880
+ 'profiles',
881
+ 'source',
882
+ 'extraRepos',
883
+ 'brands',
884
+ 'actors',
885
+ 'seededPresets',
886
+ 'hooks',
887
+ 'config',
888
+ 'hosts',
889
+ 'fleet',
890
+ 'share',
891
+ 'notify',
892
+ ];
893
+ /**
894
+ * Device-scoped Meta keys with BESPOKE routing — each lands in a special file
895
+ * (pins JSON / version-resources JSON) or a REMAPPED device-doc sub-block
896
+ * (`deviceHosts`->`hosts:`, `deviceFleet`->`fleet:`, ...), so their handling is
897
+ * hand-written in {@link writeMetaUnlocked} / {@link overlayMachineLocal} and kept
898
+ * behavior-identical. A device-scoped key NOT listed here is a GENERIC device key:
899
+ * it round-trips through `devices/<host>/agents.yaml` under its OWN name with no
900
+ * bespoke wiring (see the generic loops in those two functions). The `browser`
901
+ * tombstone is bespoke too — drained by lib/browser/registry.ts.
902
+ */
903
+ const BESPOKE_DEVICE_KEYS = [
904
+ 'agents',
905
+ 'isolatedAgents',
906
+ 'versions',
907
+ 'deviceRoutines',
908
+ 'deviceConfig',
909
+ 'deviceBrowser',
910
+ 'deviceFleet',
911
+ 'deviceHosts',
912
+ 'deviceAccounts',
913
+ 'projectRoot',
914
+ ];
915
+ const CENTRAL_KEY_SET = new Set(CENTRAL_META_KEYS);
916
+ const _metaKeysAreExhaustive = true;
917
+ void _metaKeysAreExhaustive;
918
+ /**
919
+ * Sync-domain of a Meta key. `'central'` ONLY for the opt-in allowlist; `'device'`
920
+ * by DEFAULT for everything else — so the classification is authoritative (it
921
+ * DRIVES the generic device-doc router below) rather than a decorative string map,
922
+ * and forgetting to classify a new key routes it to the SAFE per-box file.
923
+ */
924
+ function metaKeyScope(key) {
925
+ return CENTRAL_KEY_SET.has(key) ? 'central' : 'device';
926
+ }
927
+ /** Bespoke device keys as a runtime Set (the generic router skips these). */
928
+ const BESPOKE_DEVICE_KEY_SET = new Set([
929
+ ...BESPOKE_DEVICE_KEYS,
930
+ // The `browser` tombstone is device-scoped but bespoke: lib/browser/registry.ts
931
+ // drains it (collision-checked) into deviceBrowser, so the generic router must
932
+ // never blindly relocate it.
933
+ 'browser',
934
+ ]);
935
+ /**
936
+ * Device-doc sub-block names the read/write paths handle BESPOKE-ly (each maps to
937
+ * a different `device*` Meta key, or lives in a separate file). The generic
938
+ * device-doc overlay skips these so it only surfaces genuine generic keys.
939
+ */
940
+ const BESPOKE_DEVICE_DOC_KEYS = new Set([
941
+ 'agents',
942
+ 'isolatedAgents',
943
+ 'routines',
944
+ 'config',
945
+ 'browser',
946
+ 'fleet',
947
+ 'hosts',
948
+ 'accounts',
949
+ 'projectRoot',
950
+ // Legacy top-level doc key the config migration folds into `config:`; never
951
+ // surfaced onto Meta before, so keep the generic overlay from doing so.
952
+ 'defaultBrowserProfile',
953
+ ]);
904
954
  /**
905
955
  * Every key this version models (central + device). serializeCentral deletes an
906
956
  * on-disk key only when it is KNOWN and absent from the write's in-memory object:
@@ -910,11 +960,91 @@ const META_KEY_SCOPE = {
910
960
  * a newer CLI version added — is preserved verbatim, never dropped + synced away.
911
961
  */
912
962
  const KNOWN_META_KEYS = new Set([
913
- ...Object.keys(META_KEY_SCOPE),
963
+ ...CENTRAL_META_KEYS,
964
+ ...BESPOKE_DEVICE_KEYS,
914
965
  // Removed browser-profile store. Kept only as a serializer tombstone so the
915
966
  // first registry read can migrate it into this device's file and delete it.
916
967
  'browser',
917
968
  ]);
969
+ /**
970
+ * Rewrite a frozen `agents.yaml` header to the current {@link META_HEADER}.
971
+ *
972
+ * `serializeCentral` parses the existing file to preserve its hand-written body
973
+ * comments, but that also preserves the leading metadata header verbatim, so a
974
+ * top-level file written before the agi-cli rename (or before the `$schema` line
975
+ * existed) keeps its stale header forever — every freshly-written device doc gets
976
+ * the current header while the shared file is left behind (PHNX-3315).
977
+ *
978
+ * The header is healed TEXTUALLY, on the already-serialized string, rather than
979
+ * via `doc.commentBefore`: the `yaml` library folds the whole leading comment
980
+ * block onto the FIRST key's `commentBefore` when that key already carries a
981
+ * hand-written comment, so the header is not reliably the document comment — but
982
+ * it is always the top block of the output. Strip a leading `agents-cli metadata`
983
+ * header (any pre-rename variant, with or without the `$schema` line — the URL
984
+ * and schema lines are matched specifically so a hand-written body comment is
985
+ * never mistaken for a header line) and prepend the canonical header. A file with
986
+ * no recognizable header simply gains one. Body comments, which sit below the
987
+ * blank line that terminates the header, are untouched.
988
+ */
989
+ function healMetaHeader(serialized) {
990
+ const headerBlock = /^# agents-cli metadata\n# Auto-generated - do not edit manually\n(?:# (?:https:\/\/github\.com\/phnx-labs\/[^\n]*|yaml-language-server: \$schema=[^\n]*)\n)*\n?/;
991
+ const stripped = serialized.replace(headerBlock, '');
992
+ // No metadata header present (replace was a no-op) → leave the file exactly as
993
+ // it is. We heal a STALE header; we never prepend one to a file that never had
994
+ // it (that would rewrite a hand-authored, headerless central file on the first
995
+ // real central change). A current header round-trips to the identical bytes.
996
+ return stripped === serialized ? serialized : META_HEADER + stripped;
997
+ }
998
+ /**
999
+ * True when the top-level `~/.agents/agents.yaml` carries a metadata header that
1000
+ * is NOT the canonical {@link META_HEADER} — the P1 frozen-header case a box only
1001
+ * heals on its next central write (serializeCentral). An absent or headerless
1002
+ * file is NOT stale (a headerless central file is deliberately left alone).
1003
+ * Surfaced as config drift by `agents sync status` (PHNX-3315).
1004
+ */
1005
+ export function hasStaleMetaHeader() {
1006
+ let content;
1007
+ try {
1008
+ content = fs.readFileSync(META_FILE, 'utf-8');
1009
+ }
1010
+ catch {
1011
+ return false;
1012
+ }
1013
+ return healMetaHeader(content) !== content;
1014
+ }
1015
+ /**
1016
+ * Parse the top-level user `agents.yaml` (this box's synced central file) WITHOUT
1017
+ * the system-repo merge, machine-local overlay, or cache — the raw on-disk central
1018
+ * map, or null when the file is absent/unparseable. Used by config-drift detection
1019
+ * to see the central blocks that should have folded into the device doc, without
1020
+ * mis-attributing a system-repo default as this box's own leak (PHNX-3315).
1021
+ */
1022
+ export function readTopLevelUserMeta() {
1023
+ let content;
1024
+ try {
1025
+ content = fs.readFileSync(META_FILE, 'utf-8');
1026
+ }
1027
+ catch {
1028
+ return null;
1029
+ }
1030
+ try {
1031
+ const parsed = yaml.parse(content);
1032
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1033
+ return parsed;
1034
+ }
1035
+ }
1036
+ catch { /* malformed — nothing to report */ }
1037
+ return null;
1038
+ }
1039
+ /**
1040
+ * Top-level keys currently on disk in the central `agents.yaml` ({} when the file
1041
+ * is absent or unparseable). The generic device-doc router uses this to leave a
1042
+ * FOREIGN key — one this version does not model, already written to central by a
1043
+ * newer CLI — in place instead of relocating it to this box's device doc.
1044
+ */
1045
+ function readCentralKeys() {
1046
+ return new Set(Object.keys(readTopLevelUserMeta() ?? {}));
1047
+ }
918
1048
  /**
919
1049
  * Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
920
1050
  * hand-written comments in the committed file.
@@ -973,20 +1103,41 @@ function serializeCentral(central) {
973
1103
  }
974
1104
  }
975
1105
  // No central field changed → keep the file byte-identical (comments intact), so
976
- // writeIfChanged skips it and the churn loop never starts.
1106
+ // writeIfChanged skips it and the churn loop never starts. A device-only write
1107
+ // (pins/routines/etc. routed elsewhere) reaches here with changed=false and
1108
+ // MUST NOT rewrite the shared file — header healing waits for a genuine central
1109
+ // change below rather than dirtying agents.yaml on an unrelated write, which is
1110
+ // the very churn that wedges `agents sync` and blocks fleet pulls.
977
1111
  if (!changed)
978
1112
  return existing;
979
- // Everything cleared → header only (never leave a flow `{}` behind).
980
- // Otherwise stringifyDoc: it still normalizes a legacy flow root (`{}`) to
981
- // block, so edited nodes do not render flow (`disabledCommands: [ teams ]`
982
- // instead of a `- teams` block list), but it no longer forces block on a
983
- // normal document — that flattened committed flow sequences and made this
984
- // writer disagree with feed.ts/activity.ts/migrate.ts on the same file
985
- // (RUSH-2505). parseDocument still preserves comments + key ordering.
986
- return isEmpty ? META_HEADER : stringifyDoc(doc);
1113
+ // Everything cleared → header only (never leave a flow `{}` behind). Byte-stable
1114
+ // when the file is already exactly the current header.
1115
+ if (isEmpty)
1116
+ return existing === META_HEADER ? existing : META_HEADER;
1117
+ // A central key changed: serialize the edited doc and heal a frozen header on
1118
+ // the result. stringifyDoc still normalizes a legacy flow root (`{}`) to block,
1119
+ // so edited nodes do not render flow (`disabledCommands: [ teams ]` instead of a
1120
+ // `- teams` block list), but it no longer forces block on a normal document —
1121
+ // that flattened committed flow sequences and made this writer disagree with
1122
+ // feed.ts/activity.ts/migrate.ts on the same file (RUSH-2505). parseDocument
1123
+ // still preserves body comments + key ordering; healMetaHeader is a no-op unless
1124
+ // a stale metadata header is actually present, so a headerless central file is
1125
+ // updated in place without gaining one.
1126
+ return healMetaHeader(stringifyDoc(doc));
987
1127
  }
988
- function writeMetaUnlocked(meta) {
989
- const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, projectRoot, ...central } = meta;
1128
+ /**
1129
+ * Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
1130
+ * lock — the caller must already hold it via {@link withMetaLock}. Exported so a
1131
+ * writer that needs to read fresh state, decide, and commit within a SINGLE lock
1132
+ * acquisition (e.g. browser tombstone eviction) can do so without the
1133
+ * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
1134
+ */
1135
+ export function writeMetaUnlocked(meta) {
1136
+ // INVARIANT: every key destructured here must also be in BESPOKE_DEVICE_KEYS (and
1137
+ // vice versa) — a bespoke device key that is classified but NOT pulled out here
1138
+ // would fall into `central`, and the generic router skips it (BESPOKE_DEVICE_KEY_SET),
1139
+ // so it would silently sync to the shared file. Keep the two lists in lockstep.
1140
+ const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, deviceFleet, deviceHosts, deviceAccounts, projectRoot, ...central } = meta;
990
1141
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
991
1142
  // never removes pins/versions from central before they're persisted elsewhere.
992
1143
  const hasAgents = !!agents && Object.keys(agents).length > 0;
@@ -1049,11 +1200,69 @@ function writeMetaUnlocked(meta) {
1049
1200
  doc.browser = deviceBrowser;
1050
1201
  else
1051
1202
  delete doc.browser;
1203
+ // PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
1204
+ // slice; the effective fleet view is unioned across every device doc at read
1205
+ // time (lib/devices/device-docs.ts). Empty slices are dropped so a box that
1206
+ // has made no decision leaves no key behind (no committed empty maps).
1207
+ const fleetDiscovery = deviceFleet?.discovery && Object.keys(deviceFleet.discovery).length > 0
1208
+ ? deviceFleet.discovery : undefined;
1209
+ const fleetIgnored = deviceFleet?.ignored && deviceFleet.ignored.length > 0
1210
+ ? deviceFleet.ignored : undefined;
1211
+ if (fleetDiscovery || fleetIgnored) {
1212
+ const df = {};
1213
+ if (fleetDiscovery)
1214
+ df.discovery = fleetDiscovery;
1215
+ if (fleetIgnored)
1216
+ df.ignored = fleetIgnored;
1217
+ doc.fleet = df;
1218
+ }
1219
+ else
1220
+ delete doc.fleet;
1221
+ const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
1222
+ if (hasDeviceHosts)
1223
+ doc.hosts = deviceHosts;
1224
+ else
1225
+ delete doc.hosts;
1226
+ const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
1227
+ ? deviceAccounts.native : undefined;
1228
+ const accountsBindings = deviceAccounts?.bindings && Object.keys(deviceAccounts.bindings).length > 0
1229
+ ? deviceAccounts.bindings : undefined;
1230
+ if (accountsNative || accountsBindings) {
1231
+ const da = {};
1232
+ if (accountsNative)
1233
+ da.native = accountsNative;
1234
+ if (accountsBindings)
1235
+ da.bindings = accountsBindings;
1236
+ doc.accounts = da;
1237
+ }
1238
+ else
1239
+ delete doc.accounts;
1052
1240
  const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
1053
1241
  if (hasProjectRoot)
1054
1242
  doc.projectRoot = projectRoot;
1055
1243
  else
1056
1244
  delete doc.projectRoot;
1245
+ // Generic device-scoped keys (PHNX-3315): any key left in `central` that this
1246
+ // version classifies as device but does NOT bespoke-route round-trips through
1247
+ // this box's device doc under its OWN name — so a NEWLY DECLARED device-scoped
1248
+ // key lands per-box BY DEFAULT, with no bespoke wiring, and never reaches the
1249
+ // synced central file. Today the bespoke set covers every device key, so this
1250
+ // loop moves nothing (behavior-identical). A key that is UNKNOWN to this version
1251
+ // AND already present in the on-disk central file is a FOREIGN key a newer CLI
1252
+ // wrote as central — left in `central` and preserved verbatim by serializeCentral
1253
+ // (the fleet-wide config-loss guard), never relocated to this box's doc.
1254
+ const centralRecord = central;
1255
+ const onDiskCentralKeys = readCentralKeys();
1256
+ for (const k of Object.keys(centralRecord)) {
1257
+ if (metaKeyScope(k) !== 'device')
1258
+ continue; // fleet-shared: stays central
1259
+ if (BESPOKE_DEVICE_KEY_SET.has(k))
1260
+ continue; // bespoke (incl. browser tombstone)
1261
+ if (!KNOWN_META_KEYS.has(k) && onDiskCentralKeys.has(k))
1262
+ continue; // foreign — preserve
1263
+ doc[k] = centralRecord[k];
1264
+ delete centralRecord[k];
1265
+ }
1057
1266
  if (Object.keys(doc).length > 0) {
1058
1267
  fs.mkdirSync(path.dirname(devicePath), { recursive: true });
1059
1268
  writeIfChanged(devicePath, META_HEADER + yaml.stringify(doc));
@@ -1121,12 +1330,67 @@ function overlayMachineLocal(meta) {
1121
1330
  if (dm?.config && typeof dm.config === 'object' && !Array.isArray(dm.config)) {
1122
1331
  meta.deviceConfig = { ...meta.deviceConfig, ...dm.config };
1123
1332
  }
1333
+ // PHNX-3315: this box's own device-scoped fleet/hosts/accounts slices.
1334
+ // These populate the `device*` keys the writers read-modify-write; the
1335
+ // effective UNION across all boxes is computed separately by
1336
+ // lib/devices/device-docs.ts, not here (this overlay is this box only).
1337
+ // A malformed block on THIS box's own doc is a HARD error, exactly like
1338
+ // `routines` below and the cross-box union readers (device-docs.ts): a
1339
+ // silent drop would let the next writeMetaUnlocked round-trip overwrite the
1340
+ // whole block with just the new entry, discarding the rest on this box's
1341
+ // tracked file (PHNX-3315).
1342
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
1343
+ const dmRaw = dm;
1344
+ if (dmRaw.fleet !== undefined) {
1345
+ if (!isMap(dmRaw.fleet))
1346
+ throw new Error(`Device config corrupted at ${devicePath}: fleet must be a map.`);
1347
+ const df = dmRaw.fleet;
1348
+ if (df.discovery !== undefined && !isMap(df.discovery)) {
1349
+ throw new Error(`Device config corrupted at ${devicePath}: fleet.discovery must be a map.`);
1350
+ }
1351
+ if (df.ignored !== undefined && !Array.isArray(df.ignored)) {
1352
+ throw new Error(`Device config corrupted at ${devicePath}: fleet.ignored must be a list.`);
1353
+ }
1354
+ const discovery = df.discovery;
1355
+ const ignored = df.ignored;
1356
+ if (discovery || ignored)
1357
+ meta.deviceFleet = { ...(discovery ? { discovery } : {}), ...(ignored ? { ignored } : {}) };
1358
+ }
1359
+ if (dmRaw.hosts !== undefined) {
1360
+ if (!isMap(dmRaw.hosts))
1361
+ throw new Error(`Device config corrupted at ${devicePath}: hosts must be a map.`);
1362
+ meta.deviceHosts = { ...meta.deviceHosts, ...dmRaw.hosts };
1363
+ }
1364
+ if (dmRaw.accounts !== undefined) {
1365
+ if (!isMap(dmRaw.accounts))
1366
+ throw new Error(`Device config corrupted at ${devicePath}: accounts must be a map.`);
1367
+ const acc = dmRaw.accounts;
1368
+ if (acc.native !== undefined && !isMap(acc.native)) {
1369
+ throw new Error(`Device config corrupted at ${devicePath}: accounts.native must be a map.`);
1370
+ }
1371
+ if (acc.bindings !== undefined && !isMap(acc.bindings)) {
1372
+ throw new Error(`Device config corrupted at ${devicePath}: accounts.bindings must be a map.`);
1373
+ }
1374
+ const native = acc.native;
1375
+ const bindings = acc.bindings;
1376
+ if (native || bindings)
1377
+ meta.deviceAccounts = { ...(native ? { native } : {}), ...(bindings ? { bindings } : {}) };
1378
+ }
1124
1379
  if (Object.prototype.hasOwnProperty.call(dm, 'routines')) {
1125
1380
  if (!Array.isArray(dm.routines) || dm.routines.some((name) => typeof name !== 'string')) {
1126
1381
  throw new Error(`Device config corrupted at ${devicePath}: routines must be a string list.`);
1127
1382
  }
1128
1383
  meta.deviceRoutines = dm.routines;
1129
1384
  }
1385
+ // Generic device-scoped keys (PHNX-3315): device-doc keys this overlay does
1386
+ // NOT bespoke-handle map straight back onto Meta under their own name —
1387
+ // symmetry with the generic write in writeMetaUnlocked. The bespoke sub-blocks
1388
+ // are handled above and skipped, so a stock device doc surfaces nothing extra.
1389
+ for (const [k, v] of Object.entries(dm)) {
1390
+ if (BESPOKE_DEVICE_DOC_KEYS.has(k))
1391
+ continue;
1392
+ meta[k] = v;
1393
+ }
1130
1394
  }
1131
1395
  }
1132
1396
  const vrPath = getVersionResourcesPath();
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { AgentId } from './types.js';
22
22
  import { type DoctorKind } from './doctor-diff.js';
23
+ import { type ConfigDrift } from './config-drift.js';
23
24
  /**
24
25
  * One stable status per resource, unified across every surface.
25
26
  * - `synced` — installed copy matches the resolved source (DiffStatus 'ok').
@@ -76,6 +77,9 @@ export interface UserRepoStatus {
76
77
  export interface UnifiedSyncStatus {
77
78
  system: SystemRepoStatus;
78
79
  user: UserRepoStatus;
80
+ /** Config drift: has this box drained its device-scoped state, or is it still
81
+ * carrying per-box state in the shared top-level agents.yaml? (PHNX-3315) */
82
+ config: ConfigDrift;
79
83
  agents: AgentVersionStatus[];
80
84
  totals: {
81
85
  drifted: number;
@@ -26,6 +26,7 @@ import { loadManifest } from './staleness/index.js';
26
26
  import { getSystemAgentsDir, getUserAgentsDir } from './state.js';
27
27
  import * as fs from 'fs';
28
28
  import { isGitRepo, readOriginUrl } from './git.js';
29
+ import { detectConfigDrift } from './config-drift.js';
29
30
  const STATUS_MAP = {
30
31
  ok: 'synced',
31
32
  diff: 'drifted',
@@ -125,6 +126,7 @@ export async function computeSyncStatus(options = {}) {
125
126
  }
126
127
  const system = await getSystemRepoStatus();
127
128
  const user = await getUserRepoStatus();
129
+ const config = detectConfigDrift();
128
130
  const agentsNeedingSync = new Set();
129
131
  let drifted = 0, missing = 0, orphan = 0, versionsNeedingSync = 0, versionsNeverSynced = 0;
130
132
  for (const v of agents) {
@@ -141,6 +143,7 @@ export async function computeSyncStatus(options = {}) {
141
143
  return {
142
144
  system,
143
145
  user,
146
+ config,
144
147
  agents,
145
148
  totals: {
146
149
  drifted,
@@ -34,6 +34,7 @@ export type InjectResolution = {
34
34
  } | {
35
35
  addressable: false;
36
36
  reason: string;
37
+ hint?: string;
37
38
  };
38
39
  export interface ResolveOptions {
39
40
  /**
@@ -49,6 +50,12 @@ export interface ResolveOptions {
49
50
  */
50
51
  ptyId?: string;
51
52
  }
53
+ /**
54
+ * Human-facing recovery hint for a session the resolver judged un-addressable.
55
+ * Tells the user both how to continue THIS session and how to make FUTURE runs
56
+ * addressable, so the failure is not silent and the fix is actionable.
57
+ */
58
+ export declare function addressabilityRecoveryHint(session: ActiveSession, fallbackId?: string): string;
52
59
  /**
53
60
  * Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
54
61
  * precedence logic is unit-testable without the process table. This is where the
@@ -1,10 +1,40 @@
1
1
  import { getActiveSessions } from '../session/active.js';
2
+ import { machineId } from '../machine-id.js';
2
3
  /** The editor CLIs that speak the swarm-ext URI protocol, keyed by the host detectHost() reports. */
3
4
  const IDE_INJECT_VARIANTS = {
4
5
  codium: { cli: 'codium', scheme: 'vscodium' },
5
6
  cursor: { cli: 'cursor', scheme: 'cursor' },
6
7
  code: { cli: 'code', scheme: 'vscode' },
7
8
  };
9
+ /**
10
+ * Human-facing recovery hint for a session the resolver judged un-addressable.
11
+ * Tells the user both how to continue THIS session and how to make FUTURE runs
12
+ * addressable, so the failure is not silent and the fix is actionable.
13
+ */
14
+ export function addressabilityRecoveryHint(session, fallbackId) {
15
+ const sid = session.sessionId;
16
+ // Branch on the live sessionId (an IDE terminal that has not registered one
17
+ // yet is a distinct message), but render the resume command with the real id
18
+ // when the caller can supply it — e.g. `focus` has meta.id even though the
19
+ // live row's sessionId is falsy, so without this the hint printed a useless
20
+ // `agents sessions resume <id>` placeholder in exactly that case (PHNX-3070).
21
+ const resumeId = sid ?? fallbackId;
22
+ const shortId = resumeId ? resumeId.slice(0, 8) : '<id>';
23
+ const device = session.machine ?? machineId();
24
+ const resumeCmd = resumeId ? `agents sessions resume ${shortId}` : 'agents sessions resume <id>';
25
+ const tmuxCmd = `agents config set devices.${device}.tmux on`;
26
+ const interactive = session.context === 'terminal' || !!session.tty;
27
+ if (session.host === 'ghostty') {
28
+ return `Ghostty has no per-split addressing. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
29
+ }
30
+ if (session.host && session.host in IDE_INJECT_VARIANTS && !sid) {
31
+ return `This IDE terminal has not registered a session id yet. Wait a moment and retry, or use \`${resumeCmd}\` to continue.`;
32
+ }
33
+ if (session.host) {
34
+ return `Host '${session.host}' has no addressable rail here. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
35
+ }
36
+ return `This session has no addressable terminal rail (not tmux, iTerm, an IDE terminal, or a pty sidecar). ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
37
+ }
8
38
  /**
9
39
  * Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
10
40
  * precedence logic is unit-testable without the process table. This is where the
@@ -35,7 +65,11 @@ export function resolveInjectTargetForSession(session, opts = {}) {
35
65
  const variant = session.host ? IDE_INJECT_VARIANTS[session.host] : undefined;
36
66
  if (variant) {
37
67
  if (!session.sessionId) {
38
- return { addressable: false, reason: `IDE terminal (${session.host}) has no session id to address` };
68
+ return {
69
+ addressable: false,
70
+ reason: `IDE terminal (${session.host}) has no session id to address`,
71
+ hint: addressabilityRecoveryHint(session),
72
+ };
39
73
  }
40
74
  return {
41
75
  addressable: true,
@@ -58,13 +92,18 @@ export function resolveInjectTargetForSession(session, opts = {}) {
58
92
  note: 'coarse Ghostty window path (opt-in): raises a window and types into the FOCUSED split — not split-precise',
59
93
  };
60
94
  }
61
- return { addressable: false, reason: 'un-addressable (ghostty, no tmux): no per-split addressing; watchdog skips' };
95
+ return {
96
+ addressable: false,
97
+ reason: 'un-addressable (ghostty, no tmux): no per-split addressing; watchdog skips',
98
+ hint: addressabilityRecoveryHint(session),
99
+ };
62
100
  }
63
101
  return {
64
102
  addressable: false,
65
103
  reason: session.host
66
104
  ? `no precise inject rail for host '${session.host}' (no tmux/iterm/IDE terminal detected)`
67
105
  : 'no inject rail: session is not inside tmux, iTerm, or an IDE terminal',
106
+ hint: addressabilityRecoveryHint(session),
68
107
  };
69
108
  }
70
109
  /**