@phnx-labs/agents-cli 1.22.57 → 1.22.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -8,12 +8,8 @@ import type { DeviceProfile } from './registry.js';
8
8
  * rewrites the cache, unreachable boxes included).
9
9
  */
10
10
  export declare const STATS_STALE_MS: number;
11
- /** Static hardware totals are valid for seven days. */
12
- export declare const SPECS_STALE_MS: number;
13
11
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
14
12
  export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
15
- /** True when cached core, RAM-total, and disk-total facts remain current. */
16
- export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
17
13
  /**
18
14
  * Carry a device's last successfully-probed hardware facts onto a row whose
19
15
  * probe just came back unreachable (RUSH-3096).
@@ -54,8 +50,6 @@ export interface FleetStatsResult {
54
50
  export interface LoadFleetStatsOptions {
55
51
  /** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
56
52
  forceRefresh?: boolean;
57
- /** Read only static hardware facts, whose cache lifetime is seven days. */
58
- specsOnly?: boolean;
59
53
  /** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
60
54
  selfName?: string;
61
55
  /** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
@@ -37,17 +37,10 @@ const CACHE_FILE = '.fleet-stats.json';
37
37
  * rewrites the cache, unreachable boxes included).
38
38
  */
39
39
  export const STATS_STALE_MS = 3 * 60_000;
40
- /** Static hardware totals are valid for seven days. */
41
- export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
42
40
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
43
41
  export function isFreshDeviceStats(stats, now = Date.now()) {
44
42
  return now - stats.fetchedAt <= STATS_STALE_MS;
45
43
  }
46
- /** True when cached core, RAM-total, and disk-total facts remain current. */
47
- export function isFreshDeviceSpecs(stats, now = Date.now()) {
48
- const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
49
- return now - fetchedAt <= SPECS_STALE_MS;
50
- }
51
44
  /**
52
45
  * Carry a device's last successfully-probed hardware facts onto a row whose
53
46
  * probe just came back unreachable (RUSH-3096).
@@ -141,13 +134,13 @@ export async function loadFleetStats(devices, opts = {}) {
141
134
  const toProbe = [];
142
135
  let servedFromCache = false;
143
136
  for (const d of devices) {
144
- if (d.name === self && !opts.specsOnly) {
137
+ if (d.name === self) {
145
138
  // This machine is always probed locally — cheap, no ssh, always live.
146
139
  toProbe.push(d);
147
140
  continue;
148
141
  }
149
142
  const cached = opts.forceRefresh ? undefined : cache[d.name];
150
- const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
143
+ const cacheFresh = cached && isFreshDeviceStats(cached, now);
151
144
  if (cached && cacheFresh) {
152
145
  stats.set(d.name, cached);
153
146
  servedFromCache = true;
@@ -82,6 +82,20 @@ export interface VersionResourceReport {
82
82
  * probe), not the pure diff — undefined when uncomputed. */
83
83
  sourceBehind?: SourceLayerBehind[];
84
84
  }
85
+ /**
86
+ * True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
87
+ * FILE — the shape git produces on a client without symlink support (Windows
88
+ * without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
89
+ * link target (a relative path, no trailing newline) instead of the pointed-to
90
+ * content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
91
+ * skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
92
+ * GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
93
+ * an independent resource that no sync can ever reconcile (PHNX-3187). The
94
+ * signal is unambiguous: a whole file with no newline whose entire content
95
+ * resolves to an existing sibling path. Real markdown rule files always contain
96
+ * newlines, so this never false-positives on genuine content.
97
+ */
98
+ export declare function isCheckedOutSymlink(filePath: string): boolean;
85
99
  /**
86
100
  * Describe how a version's marketplace MIRROR of a plugin diverges from its
87
101
  * central source — the detail presence-only checks miss. Surfaces a stale mirror
@@ -29,9 +29,11 @@ import { discoverPlugins, marketplaceSpecForName } from './plugins/plugins.js';
29
29
  import { pluginInstallDir, repairableManifestFields } from './plugins/plugin-marketplace.js';
30
30
  import { markdownToToml } from './convert.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir, listPluginCommandNames } from './commands.js';
32
- import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
32
+ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName, skillSourceExists, readSkillSourceCommandMarker } from './command-skills.js';
33
+ import { trustedSkillRoots } from './staleness/writers/sources.js';
33
34
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
35
  import { supports } from './capabilities.js';
36
+ import { isDirectoryDoc } from './resources.js';
35
37
  import { listSkillsInVersionHome, getVersionSkillsDir } from './plugins/skills.js';
36
38
  import { listHookEntriesFromDir } from './hooks/install.js';
37
39
  import { getResourceInventory } from './resource-inventory.js';
@@ -61,6 +63,42 @@ function readSafe(file) {
61
63
  function fileExists(p) {
62
64
  return !!p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink();
63
65
  }
66
+ /**
67
+ * True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
68
+ * FILE — the shape git produces on a client without symlink support (Windows
69
+ * without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
70
+ * link target (a relative path, no trailing newline) instead of the pointed-to
71
+ * content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
72
+ * skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
73
+ * GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
74
+ * an independent resource that no sync can ever reconcile (PHNX-3187). The
75
+ * signal is unambiguous: a whole file with no newline whose entire content
76
+ * resolves to an existing sibling path. Real markdown rule files always contain
77
+ * newlines, so this never false-positives on genuine content.
78
+ */
79
+ export function isCheckedOutSymlink(filePath) {
80
+ let content;
81
+ try {
82
+ content = fs.readFileSync(filePath, 'utf-8');
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ // A git symlink blob is the bare target with no newline; any newline means
88
+ // this is real file content, not a link.
89
+ if (content.length === 0 || content.length > 255 || /[\r\n]/.test(content))
90
+ return false;
91
+ const target = content.trim();
92
+ if (!target)
93
+ return false;
94
+ const resolved = path.resolve(path.dirname(filePath), target);
95
+ try {
96
+ return fs.statSync(resolved).isFile();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
64
102
  function findFirst(candidates) {
65
103
  for (const c of candidates) {
66
104
  if (fileExists(c.path) || (fs.existsSync(c.path) && fs.lstatSync(c.path).isDirectory())) {
@@ -115,16 +153,46 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
115
153
  if (!entry.isFile() || !entry.name.endsWith('.md'))
116
154
  continue;
117
155
  const name = entry.name.replace(/\.md$/, '');
156
+ // Directory docs (README/AGENTS/CLAUDE/GEMINI) live in commands/ but are
157
+ // documentation, not commands. `discoverCommands` and `resolveResource`
158
+ // both refuse them, so the sync writer never installs them — mirror that
159
+ // here or every one false-reports as a missing command no sync can clear.
160
+ if (isDirectoryDoc('commands', name))
161
+ continue;
118
162
  if (sourceByName.has(name))
119
163
  continue;
120
164
  sourceByName.set(name, { layer: base.layer, path: path.join(base.path, entry.name), alias: base.alias });
121
165
  }
122
166
  }
167
+ // The trusted skill roots the commands-as-skills writer consults to decide
168
+ // whether a real skill of the same name already owns a command's target slot.
169
+ const skillRoots = asSkill ? trustedSkillRoots() : [];
123
170
  const rows = [];
124
171
  const seen = new Set();
125
172
  for (const [name, src] of sourceByName) {
126
173
  seen.add(name);
127
174
  if (!installed.has(name)) {
175
+ // Command-as-skill agents (codex >= 0.117, kimi): when a real skill of the
176
+ // same name exists in the sources, the skill wins the shared
177
+ // `skills/<name>/` slot and `installCommandSkillToVersion` deliberately
178
+ // writes no command wrapper (versions.ts keeps the real skill in
179
+ // skillsToSync and overwrites any wrapper). The command's behavior is
180
+ // provided by that same-named skill, so it is NOT missing drift — reporting
181
+ // it so drove an unclearable "N missing" loop (PHNX-3186). Mirror the
182
+ // writer's skip predicate exactly: a skill source of this name whose
183
+ // `agents_command` marker is not this command (a real skill, or a different
184
+ // command).
185
+ if (asSkill && skillSourceExists(name, skillRoots) && readSkillSourceCommandMarker(name, skillRoots) !== name) {
186
+ rows.push({
187
+ kind: 'commands',
188
+ name,
189
+ status: 'ok',
190
+ source: src.layer,
191
+ sourcePath: src.path,
192
+ detail: 'provided by same-named skill',
193
+ });
194
+ continue;
195
+ }
128
196
  rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
129
197
  continue;
130
198
  }
@@ -180,6 +248,11 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
180
248
  for (const name of installed) {
181
249
  if (seen.has(name))
182
250
  continue;
251
+ // Directory docs are excluded from the source scan above; a leftover copy in
252
+ // the home is not a command orphan — leave it out of the command diff
253
+ // entirely rather than flip it to a spurious `extra` row.
254
+ if (isDirectoryDoc('commands', name))
255
+ continue;
183
256
  if (pluginCommands.has(name))
184
257
  continue;
185
258
  const extraHome = asSkill
@@ -382,8 +455,13 @@ function listRulesNames(cwd, excludeProject = false) {
382
455
  for (const file of entries) {
383
456
  if (!file.endsWith('.md') || file === RULES_DOC_FILENAME)
384
457
  continue;
385
- const stat = fs.lstatSync(path.join(base.path, file));
386
- if (stat.isSymbolicLink())
458
+ const filePath = path.join(base.path, file);
459
+ const stat = fs.lstatSync(filePath);
460
+ // Skip the CLAUDE.md / GEMINI.md alias symlinks — both when they are real
461
+ // symlinks (posix) and when git checked them out as plain text files
462
+ // (Windows), so they are never mistaken for independent rule sources the
463
+ // writer can't produce (PHNX-3187).
464
+ if (stat.isSymbolicLink() || isCheckedOutSymlink(filePath))
387
465
  continue;
388
466
  const name = file.replace(/\.md$/, '');
389
467
  if (out.has(name))
@@ -421,11 +499,15 @@ function diffRules(agent, version, cwd, excludeProject = false) {
421
499
  const versionHome = getVersionHomePath(agent, version);
422
500
  const configDir = path.join(versionHome, agentConfigDirName(agent));
423
501
  const sourcesByName = listRulesNames(cwd, excludeProject);
424
- // Files actually present in the version home.
502
+ // Files actually present in the version home. Include *.md siblings AND the
503
+ // agent's own instructions filename even when it is not *.md — cursor's rules
504
+ // file is `.cursorrules`, so an `.md`-only scan never saw it and reported the
505
+ // AGENTS rule `missing` on every sync, a phantom no reconcile could clear
506
+ // (PHNX-3186).
425
507
  const homeFiles = new Set();
426
508
  if (fs.existsSync(configDir)) {
427
509
  for (const f of fs.readdirSync(configDir)) {
428
- if (!f.endsWith('.md'))
510
+ if (!f.endsWith('.md') && f !== agentConfig.instructionsFile)
429
511
  continue;
430
512
  homeFiles.add(f);
431
513
  }
@@ -537,14 +619,38 @@ export function describePluginDrift(central, mirrorDir) {
537
619
  parts.push(`${mVer}→${cVer}`);
538
620
  if (mManifest && repairableManifestFields(mManifest).length > 0)
539
621
  parts.push('invalid manifest');
622
+ const centralSkills = listPluginSkillDirs(central.root);
540
623
  const mirrorSkills = new Set(listPluginSkillDirs(mirrorDir));
541
- const missSkills = listPluginSkillDirs(central.root).filter((s) => !mirrorSkills.has(s)).sort();
624
+ const missSkills = centralSkills.filter((s) => !mirrorSkills.has(s)).sort();
625
+ const centralCmds = listPluginCommandFiles(central.root);
542
626
  const mirrorCmds = new Set(listPluginCommandFiles(mirrorDir));
543
- const missCmds = listPluginCommandFiles(central.root).filter((c) => !mirrorCmds.has(c)).sort();
627
+ const missCmds = centralCmds.filter((c) => !mirrorCmds.has(c)).sort();
544
628
  if (missSkills.length)
545
629
  parts.push(`missing skill${missSkills.length > 1 ? 's' : ''}: ${missSkills.join(', ')}`);
546
630
  if (missCmds.length)
547
631
  parts.push(`missing command${missCmds.length > 1 ? 's' : ''}: ${missCmds.join(', ')}`);
632
+ // Content drift of a skill/command that exists in BOTH — the mirror kept the
633
+ // dir/file but its bytes went stale (PHNX-2955: a skill edit landed on origin
634
+ // and was pulled into central, but the per-version marketplace copy was never
635
+ // refreshed, so agents kept executing the OLD skill text while `plugins list`
636
+ // and `doctor` reported it `everywhere`/`ok`). Presence alone missed this; a
637
+ // content compare is what turns a stale mirror into a reportable `diff`.
638
+ const staleSkills = centralSkills
639
+ .filter((s) => mirrorSkills.has(s))
640
+ .filter((s) => !dirsContentMatch(path.join(central.root, 'skills', s), path.join(mirrorDir, 'skills', s)))
641
+ .sort();
642
+ const staleCmds = centralCmds
643
+ .filter((c) => mirrorCmds.has(c))
644
+ .filter((c) => {
645
+ const a = readSafe(path.join(central.root, 'commands', `${c}.md`));
646
+ const b = readSafe(path.join(mirrorDir, 'commands', `${c}.md`));
647
+ return a == null || b == null || normalize(a) !== normalize(b);
648
+ })
649
+ .sort();
650
+ if (staleSkills.length)
651
+ parts.push(`stale skill${staleSkills.length > 1 ? 's' : ''}: ${staleSkills.join(', ')}`);
652
+ if (staleCmds.length)
653
+ parts.push(`stale command${staleCmds.length > 1 ? 's' : ''}: ${staleCmds.join(', ')}`);
548
654
  return parts.length ? parts.join(', ') : null;
549
655
  }
550
656
  function diffPlugins(agent, version, cwd) {
@@ -622,8 +728,13 @@ export function diffVersionResources(agent, version, options = {}) {
622
728
  empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
623
729
  if (requested.has('permissions'))
624
730
  empty.permissions = diffPresenceOnly('permissions', available.permissions, synced.permissions);
625
- if (requested.has('subagents'))
626
- empty.subagents = diffPresenceOnly('subagents', available.subagents, synced.subagents);
731
+ // Subagents are version-gated (e.g. kimi >= 0.29.0). A version below the floor
732
+ // is never written any subagent by the sync writer, so counting the source
733
+ // ones as "missing" is phantom drift no sync can clear (PHNX-3186). Zero the
734
+ // available set when unsupported; a stale installed copy still surfaces `extra`.
735
+ if (requested.has('subagents')) {
736
+ empty.subagents = diffPresenceOnly('subagents', supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
737
+ }
627
738
  if (requested.has('plugins'))
628
739
  empty.plugins = diffPlugins(agent, version, cwd);
629
740
  if (requested.has('promptcuts'))
@@ -33,3 +33,20 @@ export interface ResolveContext {
33
33
  * sync scopes, and `login: skip` are still returned (probe/report only).
34
34
  */
35
35
  export declare function resolveDesired(manifest: FleetManifest, ctx: ResolveContext): DeviceDesired[];
36
+ /**
37
+ * The message to print when a reconcile resolved zero target devices.
38
+ *
39
+ * An explicitly empty roster (`fleet.devices: {}` — the default on a fresh box)
40
+ * is the ACTIONABLE case: the engine ran fine, there is simply nothing declared
41
+ * to converge, so name the empty key and how to fill it. A gray "nothing to
42
+ * apply" there reads like the command is dead — the exact confusion that made
43
+ * `apply` look like a broken command during the RUSH-2981 surface review
44
+ * (PHNX-3422). Any OTHER zero-target case (a `devices: all` fleet with no online
45
+ * peers, or an explicit roster whose every name was unresolved — off-tailnet,
46
+ * ignored, or a typo, the only names `resolveDesired` drops) already had its
47
+ * reason surfaced above, so it stays a plain note.
48
+ */
49
+ export declare function emptyTargetsMessage(manifest: FleetManifest): {
50
+ style: 'hint' | 'plain';
51
+ lines: string[];
52
+ };
@@ -169,3 +169,29 @@ export function resolveDesired(manifest, ctx) {
169
169
  }
170
170
  return out;
171
171
  }
172
+ /**
173
+ * The message to print when a reconcile resolved zero target devices.
174
+ *
175
+ * An explicitly empty roster (`fleet.devices: {}` — the default on a fresh box)
176
+ * is the ACTIONABLE case: the engine ran fine, there is simply nothing declared
177
+ * to converge, so name the empty key and how to fill it. A gray "nothing to
178
+ * apply" there reads like the command is dead — the exact confusion that made
179
+ * `apply` look like a broken command during the RUSH-2981 surface review
180
+ * (PHNX-3422). Any OTHER zero-target case (a `devices: all` fleet with no online
181
+ * peers, or an explicit roster whose every name was unresolved — off-tailnet,
182
+ * ignored, or a typo, the only names `resolveDesired` drops) already had its
183
+ * reason surfaced above, so it stays a plain note.
184
+ */
185
+ export function emptyTargetsMessage(manifest) {
186
+ const rosterEmpty = manifest.devices !== 'all' && Object.keys(manifest.devices).length === 0;
187
+ if (rosterEmpty) {
188
+ return {
189
+ style: 'hint',
190
+ lines: [
191
+ 'fleet.devices is empty — nothing to converge.',
192
+ 'Declare a roster in agents.yaml: `fleet: { devices: all }` for every online box, or name them (`fleet: { devices: { <name>: {} } }`), then re-run.',
193
+ ],
194
+ };
195
+ }
196
+ return { style: 'plain', lines: ['No target devices — nothing to apply.'] };
197
+ }
package/dist/lib/git.d.ts CHANGED
@@ -161,6 +161,21 @@ export declare function canonicalGitRemote(url: string): string;
161
161
  * checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
162
162
  */
163
163
  export declare function isSystemRepoRemote(remote: string | null | undefined): boolean;
164
+ /**
165
+ * True when `remote` is the origin the system repo is EXPECTED to track on this
166
+ * machine, honouring an operator's `AGENTS_SYSTEM_REPO` override.
167
+ *
168
+ * The system repo ships hooks that register as shell `command` strings run on
169
+ * every tool event, and its checkout auto-fast-forwards from origin — so a
170
+ * fast-forward from an origin the operator never chose is remote code execution
171
+ * on the next command that loads a system resource (PHNX-2957). This is the
172
+ * pinning predicate every auto-pull of the system repo gates on: pull only when
173
+ * origin is the canonical {@link isSystemRepoRemote} repo, or the exact
174
+ * `AGENTS_SYSTEM_REPO` the operator pointed at instead. Anything else — a
175
+ * repointed origin, a fork, an unset-then-swapped remote — is refused, not
176
+ * pulled. Pure string check; no git spawn.
177
+ */
178
+ export declare function isExpectedSystemRepoRemote(remote: string | null | undefined): boolean;
164
179
  /** True when two git remote URLs point at the same repo across transport forms. */
165
180
  export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
166
181
  /**
@@ -500,6 +515,29 @@ export declare function tryAutoPull(dir: string): Promise<{
500
515
  pulled: boolean;
501
516
  error?: string;
502
517
  }>;
518
+ /** Result of {@link tryAutoPullSystemRepo}. `refused` is set only when the pull
519
+ * was blocked because origin is not the expected system remote. */
520
+ export interface SystemRepoPullResult {
521
+ pulled: boolean;
522
+ error?: string;
523
+ /** True when origin is present but is NOT the expected system remote; no
524
+ * fast-forward was attempted. `actualRemote` names what was found. */
525
+ refused?: boolean;
526
+ /** The origin fetch URL that was examined (present when a remote exists). */
527
+ actualRemote?: string;
528
+ }
529
+ /**
530
+ * Auto-pull the system repo ONLY after verifying its origin is the expected
531
+ * system remote (PHNX-2957). The system repo ships hooks that run as shell on
532
+ * tool events, so fast-forwarding it from an unexpected/repointed origin is
533
+ * remote code execution. An origin that fails {@link isExpectedSystemRepoRemote}
534
+ * is REFUSED loud (`refused: true`), never pulled — the canonical system repo
535
+ * (or an operator's `AGENTS_SYSTEM_REPO`) still fast-forwards exactly as before.
536
+ *
537
+ * A system dir with no origin at all is a plain no-op (`pulled: false`), not a
538
+ * refusal — there is nothing to pull from and nothing to distrust.
539
+ */
540
+ export declare function tryAutoPullSystemRepo(dir: string): Promise<SystemRepoPullResult>;
503
541
  /**
504
542
  * How many commits `dir`'s checked-out branch is behind its upstream, read from
505
543
  * the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
package/dist/lib/git.js CHANGED
@@ -537,6 +537,33 @@ export function isSystemRepoRemote(remote) {
537
537
  const c = canonicalGitRemote(remote);
538
538
  return c === canonicalGitRemote(`https://github.com/${systemRepoSlug(DEFAULT_SYSTEM_REPO)}`);
539
539
  }
540
+ /**
541
+ * True when `remote` is the origin the system repo is EXPECTED to track on this
542
+ * machine, honouring an operator's `AGENTS_SYSTEM_REPO` override.
543
+ *
544
+ * The system repo ships hooks that register as shell `command` strings run on
545
+ * every tool event, and its checkout auto-fast-forwards from origin — so a
546
+ * fast-forward from an origin the operator never chose is remote code execution
547
+ * on the next command that loads a system resource (PHNX-2957). This is the
548
+ * pinning predicate every auto-pull of the system repo gates on: pull only when
549
+ * origin is the canonical {@link isSystemRepoRemote} repo, or the exact
550
+ * `AGENTS_SYSTEM_REPO` the operator pointed at instead. Anything else — a
551
+ * repointed origin, a fork, an unset-then-swapped remote — is refused, not
552
+ * pulled. Pure string check; no git spawn.
553
+ */
554
+ export function isExpectedSystemRepoRemote(remote) {
555
+ if (!remote)
556
+ return false;
557
+ const override = process.env.AGENTS_SYSTEM_REPO?.trim();
558
+ if (override) {
559
+ // The override is a source spec (`gh:owner/repo`) or a full clone URL. Match
560
+ // the GitHub-slug form the setup path clones, and the raw spec itself, so a
561
+ // non-GitHub override URL still verifies.
562
+ return (sameGitRemote(remote, `https://github.com/${systemRepoSlug(override)}`) ||
563
+ sameGitRemote(remote, override.replace(/^gh:/, '')));
564
+ }
565
+ return isSystemRepoRemote(remote);
566
+ }
540
567
  /** True when two git remote URLs point at the same repo across transport forms. */
541
568
  export function sameGitRemote(a, b) {
542
569
  if (!a || !b)
@@ -1665,6 +1692,37 @@ export async function tryAutoPull(dir) {
1665
1692
  return { pulled: false, error: err.message };
1666
1693
  }
1667
1694
  }
1695
+ /**
1696
+ * Auto-pull the system repo ONLY after verifying its origin is the expected
1697
+ * system remote (PHNX-2957). The system repo ships hooks that run as shell on
1698
+ * tool events, so fast-forwarding it from an unexpected/repointed origin is
1699
+ * remote code execution. An origin that fails {@link isExpectedSystemRepoRemote}
1700
+ * is REFUSED loud (`refused: true`), never pulled — the canonical system repo
1701
+ * (or an operator's `AGENTS_SYSTEM_REPO`) still fast-forwards exactly as before.
1702
+ *
1703
+ * A system dir with no origin at all is a plain no-op (`pulled: false`), not a
1704
+ * refusal — there is nothing to pull from and nothing to distrust.
1705
+ */
1706
+ export async function tryAutoPullSystemRepo(dir) {
1707
+ if (!isGitRepo(dir))
1708
+ return { pulled: false };
1709
+ let remote;
1710
+ try {
1711
+ const git = simpleGit(dir);
1712
+ const remotes = await git.getRemotes(true);
1713
+ remote = remotes.find(r => r.name === 'origin')?.refs?.fetch;
1714
+ }
1715
+ catch {
1716
+ return { pulled: false };
1717
+ }
1718
+ if (!remote)
1719
+ return { pulled: false };
1720
+ if (!isExpectedSystemRepoRemote(remote)) {
1721
+ return { pulled: false, refused: true, actualRemote: remote };
1722
+ }
1723
+ const res = await tryAutoPull(dir);
1724
+ return { ...res, actualRemote: remote };
1725
+ }
1668
1726
  /**
1669
1727
  * How many commits `dir`'s checked-out branch is behind its upstream, read from
1670
1728
  * the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
@@ -334,21 +334,37 @@ export declare function parseHookManifest(opts?: {
334
334
  }): Record<string, ManifestHook>;
335
335
  export declare function selectHookManifest(manifest: Record<string, ManifestHook>, selected: string[]): Record<string, ManifestHook>;
336
336
  /**
337
- * Hook script files present on disk that no manifest entry declares — "dead"
338
- * hooks. The registrar only wires manifest-declared hooks into an agent's
339
- * native config (settings.json / config.toml), matching the installed file to a
340
- * manifest entry by script basename. So a file whose basename matches no
341
- * manifest `script:` is never registered: it occupies the hooks dir and shows
342
- * up in listings, but no lifecycle event ever fires it.
337
+ * Hook files present in a version home but absent from every configured
338
+ * SOURCE — genuine orphans left behind by a removed/renamed source hook.
339
+ *
340
+ * The definition is deliberately source-based, not manifest-based (PHNX-2693).
341
+ * Sync copies EVERY source hook file into a version home — registered hooks AND
342
+ * the helper / test / benchmark scripts that sit alongside them — but only the
343
+ * registered ones appear in `parseHookManifest`. Diffing installed names against
344
+ * the manifest therefore flagged every source-present-but-unregistered file
345
+ * (e.g. `permission-handler`, `verify-work-state`, `*_test`, `benchmark_*`) as
346
+ * an orphan, so `agents prune cleanup` offered to trash ~1000 in-use files. An
347
+ * installed hook is only truly orphaned when NO configured source still carries
348
+ * a file of that name — which is exactly what the `prune`/`doctor` help already
349
+ * promised ("present in a version home but missing from every configured
350
+ * source").
343
351
  *
344
352
  * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
345
- * installed hook names and the manifest's script paths.
353
+ * installed hook names and the source hook script paths.
354
+ */
355
+ export declare function unmanagedHookNames(installedHookNames: string[], sourceHookScripts: string[]): string[];
356
+ /**
357
+ * Every hook script path across the resolved SOURCE roots — user
358
+ * (`~/.agents/hooks`), system (`~/.agents/.system/hooks`), and each enabled
359
+ * extra repo's `hooks/` — grouped the same way sync materializes them
360
+ * ({@link listHookEntriesFromDir}, which also descends one-level event-group
361
+ * dirs). This is the set an installed hook must be absent from to count as an
362
+ * orphan.
346
363
  */
347
- export declare function unmanagedHookNames(installedHookNames: string[], manifestScripts: string[]): string[];
364
+ export declare function listResolvedSourceHookScripts(): string[];
348
365
  /**
349
- * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
350
- * Reads the merged hook manifest silently — a diagnostic must not emit the
351
- * shadow/override warnings the registrar path prints.
366
+ * The orphan hooks (see {@link unmanagedHookNames}) sitting in one version home:
367
+ * installed hook files whose name matches no file in any configured source root.
352
368
  */
353
369
  export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
354
370
  /**
@@ -1594,33 +1594,58 @@ export function selectHookManifest(manifest, selected) {
1594
1594
  return Object.fromEntries(Object.entries(manifest).filter(([name, hook]) => selectedHooks.has(name) || selectedHooks.has(path.basename(hook.script))));
1595
1595
  }
1596
1596
  /**
1597
- * Hook script files present on disk that no manifest entry declares — "dead"
1598
- * hooks. The registrar only wires manifest-declared hooks into an agent's
1599
- * native config (settings.json / config.toml), matching the installed file to a
1600
- * manifest entry by script basename. So a file whose basename matches no
1601
- * manifest `script:` is never registered: it occupies the hooks dir and shows
1602
- * up in listings, but no lifecycle event ever fires it.
1597
+ * Hook files present in a version home but absent from every configured
1598
+ * SOURCE — genuine orphans left behind by a removed/renamed source hook.
1599
+ *
1600
+ * The definition is deliberately source-based, not manifest-based (PHNX-2693).
1601
+ * Sync copies EVERY source hook file into a version home — registered hooks AND
1602
+ * the helper / test / benchmark scripts that sit alongside them — but only the
1603
+ * registered ones appear in `parseHookManifest`. Diffing installed names against
1604
+ * the manifest therefore flagged every source-present-but-unregistered file
1605
+ * (e.g. `permission-handler`, `verify-work-state`, `*_test`, `benchmark_*`) as
1606
+ * an orphan, so `agents prune cleanup` offered to trash ~1000 in-use files. An
1607
+ * installed hook is only truly orphaned when NO configured source still carries
1608
+ * a file of that name — which is exactly what the `prune`/`doctor` help already
1609
+ * promised ("present in a version home but missing from every configured
1610
+ * source").
1603
1611
  *
1604
1612
  * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
1605
- * installed hook names and the manifest's script paths.
1613
+ * installed hook names and the source hook script paths.
1614
+ */
1615
+ export function unmanagedHookNames(installedHookNames, sourceHookScripts) {
1616
+ const inSource = new Set(sourceHookScripts.map((s) => path.basename(s).replace(/\.[^.]+$/, '')));
1617
+ return installedHookNames.filter((name) => !inSource.has(name)).sort();
1618
+ }
1619
+ /**
1620
+ * Every hook script path across the resolved SOURCE roots — user
1621
+ * (`~/.agents/hooks`), system (`~/.agents/.system/hooks`), and each enabled
1622
+ * extra repo's `hooks/` — grouped the same way sync materializes them
1623
+ * ({@link listHookEntriesFromDir}, which also descends one-level event-group
1624
+ * dirs). This is the set an installed hook must be absent from to count as an
1625
+ * orphan.
1606
1626
  */
1607
- export function unmanagedHookNames(installedHookNames, manifestScripts) {
1608
- const managed = new Set(manifestScripts.map((s) => path.basename(s).replace(/\.[^.]+$/, '')));
1609
- return installedHookNames.filter((name) => !managed.has(name)).sort();
1627
+ export function listResolvedSourceHookScripts() {
1628
+ const roots = [
1629
+ getUserHooksDir(),
1630
+ getSystemHooksDir(),
1631
+ ...getEnabledExtraRepos().map((e) => path.join(e.dir, 'hooks')),
1632
+ ];
1633
+ const scripts = [];
1634
+ for (const root of roots) {
1635
+ for (const entry of listHookEntriesFromDir(root))
1636
+ scripts.push(entry.scriptPath);
1637
+ }
1638
+ return scripts;
1610
1639
  }
1611
1640
  /**
1612
- * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
1613
- * Reads the merged hook manifest silently — a diagnostic must not emit the
1614
- * shadow/override warnings the registrar path prints.
1641
+ * The orphan hooks (see {@link unmanagedHookNames}) sitting in one version home:
1642
+ * installed hook files whose name matches no file in any configured source root.
1615
1643
  */
1616
1644
  export function listUnmanagedHooksInVersionHome(agent, version) {
1617
1645
  if (!AGENTS[agent].supportsHooks)
1618
1646
  return [];
1619
- const scripts = Object.values(parseHookManifest({ warn: false }))
1620
- .map((h) => h.script)
1621
- .filter((s) => typeof s === 'string');
1622
1647
  const installed = listHooksInVersionHome(agent, version).map((e) => e.name);
1623
- return unmanagedHookNames(installed, scripts);
1648
+ return unmanagedHookNames(installed, listResolvedSourceHookScripts());
1624
1649
  }
1625
1650
  // Codex events that support a matcher field (matches tool name or session type).
1626
1651
  // UserPromptSubmit and Stop never include a matcher.
@@ -100,6 +100,14 @@ export interface ViewAgentAccountEligibility {
100
100
  * --json`. A picker may route to a signed-out version because launching it is
101
101
  * the login flow, but it must not route to a device whose every signed-in
102
102
  * account is throttled.
103
+ *
104
+ * The sign-in gate reads the per-version `launchable` field — the strict
105
+ * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
106
+ * by the SAME launchability the local candidate uses (`collectRunCandidates` →
107
+ * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
108
+ * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
109
+ * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
110
+ * pre-fix behavior, so a rolling fleet does not regress.
103
111
  */
104
112
  export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
105
113
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
@@ -213,6 +213,14 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
213
213
  * --json`. A picker may route to a signed-out version because launching it is
214
214
  * the login flow, but it must not route to a device whose every signed-in
215
215
  * account is throttled.
216
+ *
217
+ * The sign-in gate reads the per-version `launchable` field — the strict
218
+ * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
219
+ * by the SAME launchability the local candidate uses (`collectRunCandidates` →
220
+ * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
221
+ * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
222
+ * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
223
+ * pre-fix behavior, so a rolling fleet does not regress.
216
224
  */
217
225
  export function viewAgentAccountEligibility(view, agent) {
218
226
  try {
@@ -223,12 +231,15 @@ export function viewAgentAccountEligibility(view, agent) {
223
231
  const verdicts = (row.versions ?? []).flatMap((version) => {
224
232
  if (typeof version.signedIn !== 'boolean')
225
233
  return [];
234
+ // Prefer the strict per-version launch signal; fall back to the display
235
+ // `signedIn` for an older remote CLI that does not emit `launchable`.
236
+ const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
226
237
  const throttled = version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits';
227
238
  const authBlocked = version.authVerdict !== null
228
239
  && version.authVerdict !== undefined
229
240
  && isDeadVerdict(version.authVerdict);
230
- const ready = version.signedIn && !authBlocked && !throttled;
231
- return [{ ready, pickerEligible: ready || !version.signedIn || authBlocked }];
241
+ const ready = launchable && !authBlocked && !throttled;
242
+ return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
232
243
  });
233
244
  if (verdicts.length === 0)
234
245
  return { signedIn: undefined, pickerEligible: undefined };