@phnx-labs/agents-cli 1.22.56 → 1.22.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  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/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -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';
@@ -115,16 +117,46 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
115
117
  if (!entry.isFile() || !entry.name.endsWith('.md'))
116
118
  continue;
117
119
  const name = entry.name.replace(/\.md$/, '');
120
+ // Directory docs (README/AGENTS/CLAUDE/GEMINI) live in commands/ but are
121
+ // documentation, not commands. `discoverCommands` and `resolveResource`
122
+ // both refuse them, so the sync writer never installs them — mirror that
123
+ // here or every one false-reports as a missing command no sync can clear.
124
+ if (isDirectoryDoc('commands', name))
125
+ continue;
118
126
  if (sourceByName.has(name))
119
127
  continue;
120
128
  sourceByName.set(name, { layer: base.layer, path: path.join(base.path, entry.name), alias: base.alias });
121
129
  }
122
130
  }
131
+ // The trusted skill roots the commands-as-skills writer consults to decide
132
+ // whether a real skill of the same name already owns a command's target slot.
133
+ const skillRoots = asSkill ? trustedSkillRoots() : [];
123
134
  const rows = [];
124
135
  const seen = new Set();
125
136
  for (const [name, src] of sourceByName) {
126
137
  seen.add(name);
127
138
  if (!installed.has(name)) {
139
+ // Command-as-skill agents (codex >= 0.117, kimi): when a real skill of the
140
+ // same name exists in the sources, the skill wins the shared
141
+ // `skills/<name>/` slot and `installCommandSkillToVersion` deliberately
142
+ // writes no command wrapper (versions.ts keeps the real skill in
143
+ // skillsToSync and overwrites any wrapper). The command's behavior is
144
+ // provided by that same-named skill, so it is NOT missing drift — reporting
145
+ // it so drove an unclearable "N missing" loop (PHNX-3186). Mirror the
146
+ // writer's skip predicate exactly: a skill source of this name whose
147
+ // `agents_command` marker is not this command (a real skill, or a different
148
+ // command).
149
+ if (asSkill && skillSourceExists(name, skillRoots) && readSkillSourceCommandMarker(name, skillRoots) !== name) {
150
+ rows.push({
151
+ kind: 'commands',
152
+ name,
153
+ status: 'ok',
154
+ source: src.layer,
155
+ sourcePath: src.path,
156
+ detail: 'provided by same-named skill',
157
+ });
158
+ continue;
159
+ }
128
160
  rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
129
161
  continue;
130
162
  }
@@ -180,6 +212,11 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
180
212
  for (const name of installed) {
181
213
  if (seen.has(name))
182
214
  continue;
215
+ // Directory docs are excluded from the source scan above; a leftover copy in
216
+ // the home is not a command orphan — leave it out of the command diff
217
+ // entirely rather than flip it to a spurious `extra` row.
218
+ if (isDirectoryDoc('commands', name))
219
+ continue;
183
220
  if (pluginCommands.has(name))
184
221
  continue;
185
222
  const extraHome = asSkill
@@ -421,11 +458,15 @@ function diffRules(agent, version, cwd, excludeProject = false) {
421
458
  const versionHome = getVersionHomePath(agent, version);
422
459
  const configDir = path.join(versionHome, agentConfigDirName(agent));
423
460
  const sourcesByName = listRulesNames(cwd, excludeProject);
424
- // Files actually present in the version home.
461
+ // Files actually present in the version home. Include *.md siblings AND the
462
+ // agent's own instructions filename even when it is not *.md — cursor's rules
463
+ // file is `.cursorrules`, so an `.md`-only scan never saw it and reported the
464
+ // AGENTS rule `missing` on every sync, a phantom no reconcile could clear
465
+ // (PHNX-3186).
425
466
  const homeFiles = new Set();
426
467
  if (fs.existsSync(configDir)) {
427
468
  for (const f of fs.readdirSync(configDir)) {
428
- if (!f.endsWith('.md'))
469
+ if (!f.endsWith('.md') && f !== agentConfig.instructionsFile)
429
470
  continue;
430
471
  homeFiles.add(f);
431
472
  }
@@ -537,14 +578,38 @@ export function describePluginDrift(central, mirrorDir) {
537
578
  parts.push(`${mVer}→${cVer}`);
538
579
  if (mManifest && repairableManifestFields(mManifest).length > 0)
539
580
  parts.push('invalid manifest');
581
+ const centralSkills = listPluginSkillDirs(central.root);
540
582
  const mirrorSkills = new Set(listPluginSkillDirs(mirrorDir));
541
- const missSkills = listPluginSkillDirs(central.root).filter((s) => !mirrorSkills.has(s)).sort();
583
+ const missSkills = centralSkills.filter((s) => !mirrorSkills.has(s)).sort();
584
+ const centralCmds = listPluginCommandFiles(central.root);
542
585
  const mirrorCmds = new Set(listPluginCommandFiles(mirrorDir));
543
- const missCmds = listPluginCommandFiles(central.root).filter((c) => !mirrorCmds.has(c)).sort();
586
+ const missCmds = centralCmds.filter((c) => !mirrorCmds.has(c)).sort();
544
587
  if (missSkills.length)
545
588
  parts.push(`missing skill${missSkills.length > 1 ? 's' : ''}: ${missSkills.join(', ')}`);
546
589
  if (missCmds.length)
547
590
  parts.push(`missing command${missCmds.length > 1 ? 's' : ''}: ${missCmds.join(', ')}`);
591
+ // Content drift of a skill/command that exists in BOTH — the mirror kept the
592
+ // dir/file but its bytes went stale (PHNX-2955: a skill edit landed on origin
593
+ // and was pulled into central, but the per-version marketplace copy was never
594
+ // refreshed, so agents kept executing the OLD skill text while `plugins list`
595
+ // and `doctor` reported it `everywhere`/`ok`). Presence alone missed this; a
596
+ // content compare is what turns a stale mirror into a reportable `diff`.
597
+ const staleSkills = centralSkills
598
+ .filter((s) => mirrorSkills.has(s))
599
+ .filter((s) => !dirsContentMatch(path.join(central.root, 'skills', s), path.join(mirrorDir, 'skills', s)))
600
+ .sort();
601
+ const staleCmds = centralCmds
602
+ .filter((c) => mirrorCmds.has(c))
603
+ .filter((c) => {
604
+ const a = readSafe(path.join(central.root, 'commands', `${c}.md`));
605
+ const b = readSafe(path.join(mirrorDir, 'commands', `${c}.md`));
606
+ return a == null || b == null || normalize(a) !== normalize(b);
607
+ })
608
+ .sort();
609
+ if (staleSkills.length)
610
+ parts.push(`stale skill${staleSkills.length > 1 ? 's' : ''}: ${staleSkills.join(', ')}`);
611
+ if (staleCmds.length)
612
+ parts.push(`stale command${staleCmds.length > 1 ? 's' : ''}: ${staleCmds.join(', ')}`);
548
613
  return parts.length ? parts.join(', ') : null;
549
614
  }
550
615
  function diffPlugins(agent, version, cwd) {
@@ -622,8 +687,13 @@ export function diffVersionResources(agent, version, options = {}) {
622
687
  empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
623
688
  if (requested.has('permissions'))
624
689
  empty.permissions = diffPresenceOnly('permissions', available.permissions, synced.permissions);
625
- if (requested.has('subagents'))
626
- empty.subagents = diffPresenceOnly('subagents', available.subagents, synced.subagents);
690
+ // Subagents are version-gated (e.g. kimi >= 0.29.0). A version below the floor
691
+ // is never written any subagent by the sync writer, so counting the source
692
+ // ones as "missing" is phantom drift no sync can clear (PHNX-3186). Zero the
693
+ // available set when unsupported; a stale installed copy still surfaces `extra`.
694
+ if (requested.has('subagents')) {
695
+ empty.subagents = diffPresenceOnly('subagents', supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
696
+ }
627
697
  if (requested.has('plugins'))
628
698
  empty.plugins = diffPlugins(agent, version, cwd);
629
699
  if (requested.has('promptcuts'))
@@ -502,49 +502,10 @@ export type TmuxWrapDecision =
502
502
  kind: 'undurable';
503
503
  };
504
504
  /**
505
- * Decide whether to run an interactive agent INSIDE a detached tmux session on
506
- * the shared socket (then attach the current TTY) instead of a bare spawn.
507
- *
508
- * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
509
- * unique `%pane` handle so `agents sessions --active` can tell co-located
510
- * agents apart and `agents focus` re-attaches without forking, plus scrollback
511
- * and mouse at the operator's keyboard. Off means OFF, for local and remote
512
- * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
513
- * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
514
- * when one exists and resumes the harness session from disk when it does not.
515
- * The RUSH-3125 forced remote wrap conflated durability with that preference
516
- * and surprised every operator who had explicitly left tmux off.
517
- *
518
- * One case still wraps regardless: a followed remote run whose launcher has
519
- * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
520
- * the detached pane is the run's only interface, not an ergonomics choice.
521
- *
522
- * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
523
- * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
524
- * escape hatch that silently stopped applying over `--device` would be worse
525
- * than the bare run the user asked for.
526
- *
527
- * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
505
+ * Tmux is opt-in except when a TTY-less remote launch would otherwise have no
506
+ * interface. Explicit per-run opt-outs always win.
528
507
  */
529
508
  export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
530
- /**
531
- * Build the shell command that runs an agent inside a tmux pane with the exact
532
- * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
533
- * K=V … <agent> <args…>` so:
534
- * - `env` materializes the full agent env INTO the pane, independent of the
535
- * (possibly stale, shared) tmux server environment — additive, so tmux's own
536
- * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
537
- * - `exec` replaces the shell so the agent is the pane's leaf process (clean
538
- * `#{pane_pid}`, clean signal delivery on detach/kill).
539
- * Keys are filtered to valid identifiers so exported shell functions
540
- * (`BASH_FUNC_*%%`) can't make `env` choke.
541
- *
542
- * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
543
- * the KEY names. The env map here carries resolved secrets bundles (options.env),
544
- * so the real string would embed secret VALUES — which get persisted verbatim
545
- * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
546
- * real values; the stored/informational copy uses the redacted form (RUSH-1758).
547
- */
548
509
  /**
549
510
  * True when `options.sessionId` is an id the HARNESS actually received, and so a
550
511
  * real, resumable handle — rather than one the launcher generated for its own
@@ -562,6 +523,10 @@ export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
562
523
  * or this reports a genuine id as fabricated.
563
524
  */
564
525
  export declare function isHarnessKnownSessionId(agent: AgentId, sessionId: string | undefined, resume: boolean | undefined): boolean;
526
+ /**
527
+ * Build the pane command without inheriting stale tmux-server env. Redacted
528
+ * copies keep secret values out of persisted SessionMeta commands.
529
+ */
565
530
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
566
531
  redactEnvValues?: boolean;
567
532
  envFile?: string;
package/dist/lib/exec.js CHANGED
@@ -1241,29 +1241,8 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1241
1241
  return code === 0 && stdout.trim() === '0';
1242
1242
  }
1243
1243
  /**
1244
- * Decide whether to run an interactive agent INSIDE a detached tmux session on
1245
- * the shared socket (then attach the current TTY) instead of a bare spawn.
1246
- *
1247
- * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
1248
- * unique `%pane` handle so `agents sessions --active` can tell co-located
1249
- * agents apart and `agents focus` re-attaches without forking, plus scrollback
1250
- * and mouse at the operator's keyboard. Off means OFF, for local and remote
1251
- * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
1252
- * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
1253
- * when one exists and resumes the harness session from disk when it does not.
1254
- * The RUSH-3125 forced remote wrap conflated durability with that preference
1255
- * and surprised every operator who had explicitly left tmux off.
1256
- *
1257
- * One case still wraps regardless: a followed remote run whose launcher has
1258
- * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
1259
- * the detached pane is the run's only interface, not an ergonomics choice.
1260
- *
1261
- * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
1262
- * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
1263
- * escape hatch that silently stopped applying over `--device` would be worse
1264
- * than the bare run the user asked for.
1265
- *
1266
- * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
1244
+ * Tmux is opt-in except when a TTY-less remote launch would otherwise have no
1245
+ * interface. Explicit per-run opt-outs always win.
1267
1246
  */
1268
1247
  export function resolveTmuxWrap(ctx) {
1269
1248
  // A headless `-p` run has no TTY to attach, Windows has no tmux path, and
@@ -1297,24 +1276,6 @@ export function resolveTmuxWrap(ctx) {
1297
1276
  return ctx.remoteDispatch ? { kind: 'undurable' } : { kind: 'bare' };
1298
1277
  return { kind: 'wrap' };
1299
1278
  }
1300
- /**
1301
- * Build the shell command that runs an agent inside a tmux pane with the exact
1302
- * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
1303
- * K=V … <agent> <args…>` so:
1304
- * - `env` materializes the full agent env INTO the pane, independent of the
1305
- * (possibly stale, shared) tmux server environment — additive, so tmux's own
1306
- * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
1307
- * - `exec` replaces the shell so the agent is the pane's leaf process (clean
1308
- * `#{pane_pid}`, clean signal delivery on detach/kill).
1309
- * Keys are filtered to valid identifiers so exported shell functions
1310
- * (`BASH_FUNC_*%%`) can't make `env` choke.
1311
- *
1312
- * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
1313
- * the KEY names. The env map here carries resolved secrets bundles (options.env),
1314
- * so the real string would embed secret VALUES — which get persisted verbatim
1315
- * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
1316
- * real values; the stored/informational copy uses the redacted form (RUSH-1758).
1317
- */
1318
1279
  /**
1319
1280
  * True when `options.sessionId` is an id the HARNESS actually received, and so a
1320
1281
  * real, resumable handle — rather than one the launcher generated for its own
@@ -1338,6 +1299,10 @@ export function isHarnessKnownSessionId(agent, sessionId, resume) {
1338
1299
  return true;
1339
1300
  return agent === 'claude';
1340
1301
  }
1302
+ /**
1303
+ * Build the pane command without inheriting stale tmux-server env. Redacted
1304
+ * copies keep secret values out of persisted SessionMeta commands.
1305
+ */
1341
1306
  export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
1342
1307
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1343
1308
  // envFile: source the values instead of inlining them, so no VALUE ever lands
@@ -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
@@ -152,6 +152,15 @@ export declare function getCurrentBranch(repoPath: string): Promise<string>;
152
152
  * repo" as a requested source before adopting it.
153
153
  */
154
154
  export declare function canonicalGitRemote(url: string): string;
155
+ /**
156
+ * True when a git remote URL (any transport form: ssh, https, scp-style) points
157
+ * at the system DotAgents repo — {@link DEFAULT_SYSTEM_REPO}'s current slug OR
158
+ * its `phnx-labs/.agents` rename target (PHNX-3394), which
159
+ * {@link canonicalGitRemote} folds onto it via {@link RENAMED_REMOTE_ALIASES}.
160
+ * Pure string check with no git spawn, so it is unit-testable off a live
161
+ * checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
162
+ */
163
+ export declare function isSystemRepoRemote(remote: string | null | undefined): boolean;
155
164
  /** True when two git remote URLs point at the same repo across transport forms. */
156
165
  export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
157
166
  /**
@@ -354,7 +363,10 @@ export declare function adoptUserRepoIfNeeded(dir: string, opts?: {
354
363
  needsUrl?: boolean;
355
364
  }) | null>;
356
365
  /**
357
- * Check if the repo's origin points to the system repo.
366
+ * Check if the repo's origin points to the system repo — `phnx-labs/.agents-system`
367
+ * or its GitHub rename target `phnx-labs/.agents` (PHNX-3394), across any
368
+ * transport form. Reads the dir's origin and delegates the match to the pure
369
+ * {@link isSystemRepoRemote}.
358
370
  */
359
371
  export declare function isSystemRepoOrigin(dir: string): Promise<boolean>;
360
372
  /**
package/dist/lib/git.js CHANGED
@@ -498,7 +498,7 @@ export async function getCurrentBranch(repoPath) {
498
498
  * repo" as a requested source before adopting it.
499
499
  */
500
500
  export function canonicalGitRemote(url) {
501
- return url
501
+ const canonical = url
502
502
  .trim()
503
503
  .replace(/\/+$/, '') // trailing slashes first, so a trailing-slash-after-.git still strips
504
504
  .replace(/\.git$/i, '')
@@ -506,6 +506,36 @@ export function canonicalGitRemote(url) {
506
506
  .replace(/^[^@/]+@/, '') // strip user@ (git@, ssh user)
507
507
  .replace(':', '/') // scp-style host:owner/repo → host/owner/repo (first colon only)
508
508
  .toLowerCase();
509
+ // Fold a renamed repo's old name onto its new one so both compare equal
510
+ // everywhere (see RENAMED_REMOTE_ALIASES).
511
+ return RENAMED_REMOTE_ALIASES[canonical] ?? canonical;
512
+ }
513
+ /**
514
+ * Git remotes that denote the SAME repository under an old and a new name,
515
+ * keyed by canonical `host/owner/repo`. `phnx-labs/.agents-system` was renamed
516
+ * to `phnx-labs/.agents` on GitHub (PHNX-3394); {@link DEFAULT_SYSTEM_REPO}
517
+ * still points at the pre-rename slug (GitHub's own redirect makes that
518
+ * resolve fine), so folding the new name onto it here means both compare equal
519
+ * everywhere remotes are compared: {@link sameGitRemote} (repo adoption),
520
+ * {@link isSystemRepoRemote} (the system-origin check), and the
521
+ * DotAgents-layer classifier in state.ts.
522
+ */
523
+ const RENAMED_REMOTE_ALIASES = {
524
+ 'github.com/phnx-labs/.agents': 'github.com/phnx-labs/.agents-system',
525
+ };
526
+ /**
527
+ * True when a git remote URL (any transport form: ssh, https, scp-style) points
528
+ * at the system DotAgents repo — {@link DEFAULT_SYSTEM_REPO}'s current slug OR
529
+ * its `phnx-labs/.agents` rename target (PHNX-3394), which
530
+ * {@link canonicalGitRemote} folds onto it via {@link RENAMED_REMOTE_ALIASES}.
531
+ * Pure string check with no git spawn, so it is unit-testable off a live
532
+ * checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
533
+ */
534
+ export function isSystemRepoRemote(remote) {
535
+ if (!remote)
536
+ return false;
537
+ const c = canonicalGitRemote(remote);
538
+ return c === canonicalGitRemote(`https://github.com/${systemRepoSlug(DEFAULT_SYSTEM_REPO)}`);
509
539
  }
510
540
  /** True when two git remote URLs point at the same repo across transport forms. */
511
541
  export function sameGitRemote(a, b) {
@@ -1062,18 +1092,17 @@ export async function adoptUserRepoIfNeeded(dir, opts = {}) {
1062
1092
  return adoptRepoInPlace(dir, url);
1063
1093
  }
1064
1094
  /**
1065
- * Check if the repo's origin points to the system repo.
1095
+ * Check if the repo's origin points to the system repo — `phnx-labs/.agents-system`
1096
+ * or its GitHub rename target `phnx-labs/.agents` (PHNX-3394), across any
1097
+ * transport form. Reads the dir's origin and delegates the match to the pure
1098
+ * {@link isSystemRepoRemote}.
1066
1099
  */
1067
1100
  export async function isSystemRepoOrigin(dir) {
1068
1101
  try {
1069
1102
  const git = simpleGit(dir);
1070
1103
  const remotes = await git.getRemotes(true);
1071
1104
  const origin = remotes.find(r => r.name === 'origin');
1072
- if (!origin?.refs?.fetch)
1073
- return false;
1074
- const url = origin.refs.fetch.toLowerCase();
1075
- const currentSlug = systemRepoSlug(DEFAULT_SYSTEM_REPO).toLowerCase();
1076
- return url.includes(currentSlug);
1105
+ return isSystemRepoRemote(origin?.refs?.fetch);
1077
1106
  }
1078
1107
  catch {
1079
1108
  /* not a git repo or no remotes */
@@ -44,13 +44,13 @@ export interface ExecConfigEnvCtx {
44
44
  /** resolveInteractive(options) — computed once by the caller. */
45
45
  interactive: boolean;
46
46
  /**
47
- * The role marked on THIS machine (worker | personal | undefined), resolved
48
- * once by the caller from selfConfiguredDeviceRole(). A `personal` device is
49
- * the user's own interactive box: it holds a real per-version login and the
50
- * credential decision MUST defer to it for EVERY run — interactive OR headless
51
- * — never the worker-only setup-token (RUSH-2395). Injected as a plain value
52
- * (not imported) to keep the adapter import-leaf. Absent/undefined is treated
53
- * as non-personal (worker-equivalent).
47
+ * The role marked on THIS machine (worker | personal | desktop | undefined),
48
+ * resolved once by the caller from selfConfiguredDeviceRole(). A headed device
49
+ * (`personal` or `desktop` — see isHeadedDeviceRole) holds a real per-version
50
+ * login and the credential decision MUST defer to it for EVERY run — interactive
51
+ * OR headless — never the worker-only setup-token (RUSH-2395). Injected as a
52
+ * plain value (not imported) to keep the adapter import-leaf. Absent/undefined
53
+ * is treated as non-headed (worker-equivalent).
54
54
  */
55
55
  deviceRole?: ConfiguredDeviceRole;
56
56
  /**
@@ -1,5 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { stripForeignConfigDir } from '../adapter.js';
3
+ import { isHeadedDeviceRole } from '../../device-config.js';
3
4
  export const claudeAdapter = {
4
5
  id: 'claude',
5
6
  applyExecConfigEnv(result, ctx) {
@@ -47,8 +48,8 @@ export const claudeAdapter = {
47
48
  // path — agents.ts `isClaudeCredentialFileBlank`), so this path defers to
48
49
  // Claude Code, which reads its own ACL-trusted login item without a prompt and
49
50
  // asks a present human to log in only if the login is missing.
50
- const personalDevice = ctx.deviceRole === 'personal';
51
- if (ctx.interactive || personalDevice) {
51
+ const headedDevice = isHeadedDeviceRole(ctx.deviceRole);
52
+ if (ctx.interactive || headedDevice) {
52
53
  // Drop an INHERITED copy of OUR OWN setup-token: a launch from inside a
53
54
  // headless agent's shell inherits that agent's injected value via
54
55
  // sanitizeProcessEnv(process.env) and would keep authenticating as it,
@@ -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.