@phnx-labs/agents-cli 1.20.78 → 1.20.83

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 (183) hide show
  1. package/CHANGELOG.md +595 -0
  2. package/README.md +26 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +66 -0
  12. package/dist/commands/doctor.js +596 -33
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +24 -5
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/menubar.js +12 -0
  22. package/dist/commands/models.js +1 -1
  23. package/dist/commands/packages.js +6 -6
  24. package/dist/commands/permissions.js +1 -1
  25. package/dist/commands/profiles.js +1 -1
  26. package/dist/commands/pull.d.ts +1 -1
  27. package/dist/commands/pull.js +4 -4
  28. package/dist/commands/repo.d.ts +78 -0
  29. package/dist/commands/repo.js +187 -11
  30. package/dist/commands/routines.d.ts +8 -1
  31. package/dist/commands/routines.js +241 -77
  32. package/dist/commands/rules.js +13 -12
  33. package/dist/commands/secrets.d.ts +3 -1
  34. package/dist/commands/secrets.js +100 -32
  35. package/dist/commands/sessions-inject.d.ts +7 -4
  36. package/dist/commands/sessions-inject.js +18 -18
  37. package/dist/commands/sessions-picker.js +32 -3
  38. package/dist/commands/sessions-resume.d.ts +16 -0
  39. package/dist/commands/sessions-resume.js +14 -13
  40. package/dist/commands/sessions.d.ts +115 -0
  41. package/dist/commands/sessions.js +189 -35
  42. package/dist/commands/set.d.ts +15 -0
  43. package/dist/commands/set.js +79 -0
  44. package/dist/commands/setup-fleet.d.ts +21 -0
  45. package/dist/commands/setup-fleet.js +201 -0
  46. package/dist/commands/setup-secrets.d.ts +20 -0
  47. package/dist/commands/setup-secrets.js +223 -0
  48. package/dist/commands/setup.js +17 -3
  49. package/dist/commands/skills.js +1 -1
  50. package/dist/commands/sync.js +7 -2
  51. package/dist/commands/teams-picker.js +0 -1
  52. package/dist/commands/teams.d.ts +33 -1
  53. package/dist/commands/teams.js +208 -44
  54. package/dist/commands/versions.js +10 -5
  55. package/dist/commands/view.d.ts +1 -0
  56. package/dist/commands/view.js +11 -0
  57. package/dist/index.js +52 -8
  58. package/dist/lib/acp/harnesses.js +0 -7
  59. package/dist/lib/activity.d.ts +156 -0
  60. package/dist/lib/activity.js +282 -0
  61. package/dist/lib/agents.d.ts +9 -2
  62. package/dist/lib/agents.js +72 -63
  63. package/dist/lib/browser/service.js +3 -0
  64. package/dist/lib/browser/types.d.ts +7 -0
  65. package/dist/lib/capabilities.js +6 -2
  66. package/dist/lib/crabbox/lease.js +2 -2
  67. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  68. package/dist/lib/crabbox/setup-copy.js +4 -4
  69. package/dist/lib/daemon.d.ts +18 -0
  70. package/dist/lib/daemon.js +47 -6
  71. package/dist/lib/devices/fleet.d.ts +1 -1
  72. package/dist/lib/devices/fleet.js +1 -1
  73. package/dist/lib/doctor-diff.d.ts +20 -0
  74. package/dist/lib/doctor-diff.js +6 -1
  75. package/dist/lib/drift.d.ts +31 -11
  76. package/dist/lib/drift.js +58 -7
  77. package/dist/lib/events.d.ts +8 -1
  78. package/dist/lib/events.js +10 -1
  79. package/dist/lib/exec.d.ts +7 -0
  80. package/dist/lib/exec.js +69 -11
  81. package/dist/lib/feed-post.d.ts +28 -1
  82. package/dist/lib/feed-post.js +110 -2
  83. package/dist/lib/fleet/apply.js +8 -1
  84. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  85. package/dist/lib/fleet/auth-sync.js +25 -11
  86. package/dist/lib/fleet/remote-login.js +5 -0
  87. package/dist/lib/git.d.ts +14 -14
  88. package/dist/lib/git.js +41 -44
  89. package/dist/lib/hooks.d.ts +71 -0
  90. package/dist/lib/hooks.js +245 -95
  91. package/dist/lib/hosts/dispatch.d.ts +28 -1
  92. package/dist/lib/hosts/dispatch.js +33 -5
  93. package/dist/lib/hosts/option.js +2 -2
  94. package/dist/lib/hosts/passthrough.d.ts +22 -1
  95. package/dist/lib/hosts/passthrough.js +238 -13
  96. package/dist/lib/hosts/run-target.d.ts +2 -0
  97. package/dist/lib/hosts/run-target.js +1 -0
  98. package/dist/lib/hq/floor.d.ts +1 -1
  99. package/dist/lib/hq/floor.js +6 -1
  100. package/dist/lib/mcp.js +3 -71
  101. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  102. package/dist/lib/menubar/install-menubar.d.ts +24 -0
  103. package/dist/lib/menubar/install-menubar.js +52 -2
  104. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  105. package/dist/lib/menubar/notify-desktop.js +41 -5
  106. package/dist/lib/merged-resources.d.ts +11 -0
  107. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  108. package/dist/lib/permissions.d.ts +0 -32
  109. package/dist/lib/permissions.js +4 -157
  110. package/dist/lib/picker.js +1 -1
  111. package/dist/lib/platform/process.d.ts +16 -5
  112. package/dist/lib/platform/process.js +54 -0
  113. package/dist/lib/plugins.d.ts +0 -8
  114. package/dist/lib/plugins.js +4 -110
  115. package/dist/lib/project-resources.js +4 -1
  116. package/dist/lib/refresh.d.ts +5 -3
  117. package/dist/lib/refresh.js +7 -5
  118. package/dist/lib/resources/commands.js +4 -1
  119. package/dist/lib/resources/mcp.js +0 -4
  120. package/dist/lib/resources/permissions.d.ts +1 -1
  121. package/dist/lib/resources/permissions.js +1 -5
  122. package/dist/lib/resources/rules.js +4 -1
  123. package/dist/lib/resources/skills.js +4 -1
  124. package/dist/lib/resources/subagents.js +4 -0
  125. package/dist/lib/resources/types.d.ts +1 -1
  126. package/dist/lib/routines.d.ts +33 -0
  127. package/dist/lib/routines.js +122 -0
  128. package/dist/lib/run-defaults.d.ts +1 -0
  129. package/dist/lib/run-defaults.js +9 -0
  130. package/dist/lib/runner.d.ts +0 -12
  131. package/dist/lib/runner.js +49 -10
  132. package/dist/lib/scheduler.js +16 -4
  133. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  134. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  135. package/dist/lib/secrets/agent.d.ts +16 -0
  136. package/dist/lib/secrets/agent.js +105 -3
  137. package/dist/lib/secrets/audit.d.ts +46 -0
  138. package/dist/lib/secrets/audit.js +56 -0
  139. package/dist/lib/secrets/bundles.d.ts +11 -8
  140. package/dist/lib/secrets/bundles.js +31 -23
  141. package/dist/lib/secrets/index.d.ts +2 -1
  142. package/dist/lib/secrets/index.js +8 -2
  143. package/dist/lib/secrets/remote.d.ts +75 -2
  144. package/dist/lib/secrets/remote.js +139 -5
  145. package/dist/lib/secrets/sync.js +3 -3
  146. package/dist/lib/session/active.d.ts +128 -26
  147. package/dist/lib/session/active.js +192 -53
  148. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  149. package/dist/lib/session/actor-sidecar.js +101 -0
  150. package/dist/lib/session/db.d.ts +4 -1
  151. package/dist/lib/session/db.js +65 -8
  152. package/dist/lib/session/discover.d.ts +4 -0
  153. package/dist/lib/session/discover.js +13 -0
  154. package/dist/lib/session/hook-sessions.d.ts +9 -0
  155. package/dist/lib/session/hook-sessions.js +57 -8
  156. package/dist/lib/session/pid-registry.d.ts +13 -0
  157. package/dist/lib/session/render.d.ts +2 -0
  158. package/dist/lib/session/render.js +1 -1
  159. package/dist/lib/session/types.d.ts +16 -0
  160. package/dist/lib/staleness/detectors/commands.js +2 -2
  161. package/dist/lib/staleness/detectors/permissions.js +1 -45
  162. package/dist/lib/staleness/writers/commands.js +4 -5
  163. package/dist/lib/staleness/writers/hooks.js +1 -1
  164. package/dist/lib/startup/command-registry.d.ts +1 -2
  165. package/dist/lib/startup/command-registry.js +2 -4
  166. package/dist/lib/state.js +57 -1
  167. package/dist/lib/subagents-registry.js +5 -12
  168. package/dist/lib/subagents.d.ts +0 -10
  169. package/dist/lib/subagents.js +0 -12
  170. package/dist/lib/teams/agents.d.ts +0 -2
  171. package/dist/lib/teams/agents.js +10 -27
  172. package/dist/lib/teams/index.d.ts +1 -1
  173. package/dist/lib/teams/index.js +1 -1
  174. package/dist/lib/types.d.ts +17 -9
  175. package/dist/lib/usage.d.ts +28 -5
  176. package/dist/lib/usage.js +271 -8
  177. package/dist/lib/versions.js +7 -1
  178. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  179. package/dist/lib/watchdog/watchdog.js +25 -13
  180. package/package.json +1 -2
  181. package/dist/commands/check.d.ts +0 -15
  182. package/dist/commands/check.js +0 -180
  183. package/dist/commands/resources.d.ts +0 -5
package/dist/lib/hooks.js CHANGED
@@ -13,9 +13,8 @@ import * as os from 'os';
13
13
  import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
15
  import * as TOML from 'smol-toml';
16
- import { AGENTS, agentConfigDirName } from './agents.js';
16
+ import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
17
17
  import { supports, explainSkip, capableAgents } from './capabilities.js';
18
- import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
19
18
  import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
20
19
  import { collectSubruleHooksFromState } from './rules/compose.js';
21
20
  function getCentralHooksDir() { return getUserHooksDir(); }
@@ -147,9 +146,113 @@ function isStaleSiblingVersionCommand(command, current) {
147
146
  const id = versionHomeIdentity(command);
148
147
  return id !== null && id.agent === current.agent && id.version !== current.version;
149
148
  }
150
- import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
151
- import { generateHookShim, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
149
+ function hookResourceName(command) {
150
+ const withoutArgs = command.trim().split(/\s+/)[0];
151
+ return path.basename(withoutArgs).replace(/\.[^.]+$/, '');
152
+ }
153
+ /**
154
+ * Collapse version-scoped registrations by logical resource identity rather
155
+ * than absolute command path. When the same resource is present in several
156
+ * homes, the active home's command wins even if it appears later. Commands
157
+ * outside version homes are user-owned and remain untouched.
158
+ */
159
+ export function deduplicateVersionHookCommands(commands, activeVersionHome) {
160
+ const active = versionHomeIdentity(activeVersionHome);
161
+ if (!active)
162
+ return [...commands];
163
+ const selected = new Map();
164
+ const passthrough = [];
165
+ for (const command of commands) {
166
+ const identity = versionHomeIdentity(command);
167
+ if (!identity || identity.agent !== active.agent) {
168
+ passthrough.push(command);
169
+ continue;
170
+ }
171
+ const key = `${identity.agent}\0${hookResourceName(command)}`;
172
+ const candidateIsActive = identity.version === active.version;
173
+ const prior = selected.get(key);
174
+ if (!prior || (!prior.active && candidateIsActive)) {
175
+ selected.set(key, { command, active: candidateIsActive });
176
+ }
177
+ }
178
+ return [...passthrough, ...Array.from(selected.values(), ({ command }) => command)];
179
+ }
180
+ /**
181
+ * Collapse a matcher group's hook entries down to the deduplicated command
182
+ * multiset {@link deduplicateVersionHookCommands} returns, preserving order and
183
+ * the concrete entry objects. The deduped list is honored as a per-command
184
+ * budget, NOT a membership set: two identical active-version entries collapse to
185
+ * one because the budget for that command is one. (A membership `Set.has` test
186
+ * would keep both — the bug this replaces.) Passthrough (user / non-version-home)
187
+ * commands keep their original multiplicity, so genuine user hooks are untouched.
188
+ */
189
+ function collapseVersionHookEntries(entries, activeVersionHome) {
190
+ const budget = new Map();
191
+ for (const command of deduplicateVersionHookCommands(entries.map((e) => e.command), activeVersionHome)) {
192
+ budget.set(command, (budget.get(command) ?? 0) + 1);
193
+ }
194
+ return entries.filter((e) => {
195
+ const remaining = budget.get(e.command) ?? 0;
196
+ if (remaining <= 0)
197
+ return false;
198
+ budget.set(e.command, remaining - 1);
199
+ return true;
200
+ });
201
+ }
202
+ import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
203
+ import { generateHookShim, getHookShimPath, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
152
204
  import { getHookShimsDir } from './state.js';
205
+ function hookContentHash(scriptPath) {
206
+ return crypto.createHash('sha256').update(fs.readFileSync(scriptPath)).digest('hex');
207
+ }
208
+ /**
209
+ * Inspect every installed hooks-capable harness without assuming a native
210
+ * settings format. Same-name copies with one content hash are installation
211
+ * noise; multiple hashes are drift. The active version is always selected as
212
+ * the authoritative copy when it participates in the group.
213
+ */
214
+ export function inspectDuplicateVersionHooks(cwd = process.cwd()) {
215
+ const byResource = new Map();
216
+ for (const { agent, version } of iterHooksCapableVersions()) {
217
+ const activeVersion = resolveVersion(agent, cwd);
218
+ for (const entry of listHooksInVersionHome(agent, version)) {
219
+ let hash;
220
+ try {
221
+ hash = hookContentHash(entry.scriptPath);
222
+ }
223
+ catch {
224
+ continue;
225
+ }
226
+ const copy = {
227
+ agent,
228
+ version,
229
+ name: entry.name,
230
+ path: entry.scriptPath,
231
+ hash,
232
+ active: version === activeVersion,
233
+ };
234
+ const key = `${agent}\0${entry.name}`;
235
+ const copies = byResource.get(key) ?? [];
236
+ copies.push(copy);
237
+ byResource.set(key, copies);
238
+ }
239
+ }
240
+ const findings = [];
241
+ for (const copies of byResource.values()) {
242
+ if (copies.length < 2)
243
+ continue;
244
+ copies.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }));
245
+ const authoritative = copies.find((copy) => copy.active) ?? copies[copies.length - 1];
246
+ findings.push({
247
+ agent: authoritative.agent,
248
+ name: authoritative.name,
249
+ kind: new Set(copies.map((copy) => copy.hash)).size === 1 ? 'duplicate' : 'drift',
250
+ authoritative,
251
+ copies,
252
+ });
253
+ }
254
+ return findings.sort((a, b) => `${a.agent}/${a.name}`.localeCompare(`${b.agent}/${b.name}`));
255
+ }
153
256
  /**
154
257
  * Resolve the command path to register for a hook.
155
258
  *
@@ -488,6 +591,130 @@ export function getVersionHooksDir(agent, version) {
488
591
  export function listHooksInVersionHome(agent, version) {
489
592
  return listHookEntriesFromDir(getVersionHooksDir(agent, version));
490
593
  }
594
+ // ─── wiring inspection (settings.json family: claude, droid) ──────────────────
595
+ /**
596
+ * Agents whose hooks register through {@link registerHooksForClaude} — a native
597
+ * settings.json shaped `hooks[event] = [{ matcher, hooks: [{ command }] }]`, with
598
+ * no event renaming. These are the only agents this read-only wiring inspector
599
+ * understands; every other harness uses a divergent config format and/or event
600
+ * map (Gemini/Antigravity settings.json variants, Codex config.toml, the OpenCode
601
+ * plugin, …), so it reports them unsupported rather than risk a false verdict.
602
+ */
603
+ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid'];
604
+ /**
605
+ * Verify that every hook the manifest says should be wired for a (claude|droid)
606
+ * version is actually REFERENCED in that version's native settings.json — not
607
+ * merely present as a file on disk.
608
+ *
609
+ * `agents doctor` compares hook FILES against source (see diffHooks in
610
+ * doctor-diff.ts) but never checks the wiring, so a hook whose script is
611
+ * byte-identical to source yet missing from settings.json's PreToolUse/Stop/…
612
+ * array reads as "ok" while it never fires. This closes that blind spot.
613
+ *
614
+ * Read-only by construction: it mirrors registerHooksForClaude's command
615
+ * resolution WITHOUT the shim-generation / chmod side effects that
616
+ * resolveHookCommand performs, so it never mutates the version home.
617
+ */
618
+ export function checkVersionHookWiring(agent, version) {
619
+ if (!AGENTS[agent].supportsHooks || !SETTINGS_JSON_HOOK_FAMILY.includes(agent)) {
620
+ return { supported: false, unwired: [] };
621
+ }
622
+ const versionHome = getVersionHomePath(agent, version);
623
+ const settingsPath = path.join(versionHome, agentConfigDirName(agent), 'settings.json');
624
+ const localHooksDir = getVersionHooksDir(agent, version);
625
+ // Resolve ONLY to a script that was actually synced for THIS agent+version: the
626
+ // copy in the version home hooks dir, or an absolute subrule-dir path (those are
627
+ // registered in place, never copied). Deliberately NO central-source fallback —
628
+ // sync wires `selectHookManifest(parseHookManifest(), hooksToSync)` where
629
+ // hooksToSync is the per-agent selected set (versions.ts), so a hook the manifest
630
+ // declares but that was never synced into THIS version home (e.g. a claude-scoped
631
+ // hook viewed for droid) must not be expected here. A missing-but-declared hook
632
+ // is a FILE gap that diffHooks reports as `missing`, not a wiring gap.
633
+ // No ensureExecutable — that chmods, and this path must not touch disk.
634
+ const resolveScript = (script) => {
635
+ if (path.isAbsolute(script) && fs.existsSync(script))
636
+ return script;
637
+ return resolveContainedHookPath(localHooksDir, script);
638
+ };
639
+ // Read-only mirror of resolveHookCommand — same command string, no shim write.
640
+ const expectedCommand = (name, hookDef) => {
641
+ const scriptPath = resolveScript(hookDef.script);
642
+ if (!scriptPath)
643
+ return null;
644
+ if (!isValidHookShimName(name))
645
+ return null;
646
+ const cache = parseCacheConfig(hookDef.cache);
647
+ const hasMatches = hookDef.matches != null && Object.keys(hookDef.matches).length > 0;
648
+ if (!cache && !hasMatches)
649
+ return toPortableCommand(scriptPath);
650
+ return toPortableCommand(getHookShimPath(name));
651
+ };
652
+ const manifest = parseHookManifest({ warn: false });
653
+ const expected = [];
654
+ for (const [name, hookDef] of Object.entries(manifest)) {
655
+ if (!hookDef.events || hookDef.events.length === 0)
656
+ continue;
657
+ const command = expectedCommand(name, hookDef);
658
+ if (!command)
659
+ continue; // script unresolved — a file gap, reported by diffHooks
660
+ // Mirror registerHooksForClaude: a hook registers under the matcher group
661
+ // `hookDef.matcher || ''` for each of its events.
662
+ const matcher = hookDef.matcher || '';
663
+ for (const event of hookDef.events)
664
+ expected.push({ name, event, matcher, command });
665
+ }
666
+ if (!fs.existsSync(settingsPath)) {
667
+ return {
668
+ supported: true,
669
+ settingsPath,
670
+ expected: expected.length,
671
+ settingsMissing: expected.length > 0,
672
+ unwired: [],
673
+ };
674
+ }
675
+ let config;
676
+ try {
677
+ config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
678
+ }
679
+ catch {
680
+ return {
681
+ supported: true,
682
+ settingsPath,
683
+ expected: expected.length,
684
+ settingsUnparseable: true,
685
+ unwired: [],
686
+ };
687
+ }
688
+ // Command strings actually referenced, keyed by (event, matcher) — a hook wired
689
+ // under the WRONG matcher group must NOT read as wired, so scope by matcher and
690
+ // not just by event.
691
+ const wiredByGroup = new Map();
692
+ const groupKey = (event, matcher) => `${event}\n${matcher}`;
693
+ const hooks = config.hooks && typeof config.hooks === 'object'
694
+ ? config.hooks
695
+ : {};
696
+ for (const [event, groups] of Object.entries(hooks)) {
697
+ if (!Array.isArray(groups))
698
+ continue;
699
+ for (const group of groups) {
700
+ if (!group || !Array.isArray(group.hooks))
701
+ continue;
702
+ const matcher = typeof group.matcher === 'string' ? group.matcher : '';
703
+ const key = groupKey(event, matcher);
704
+ let cmds = wiredByGroup.get(key);
705
+ if (!cmds) {
706
+ cmds = new Set();
707
+ wiredByGroup.set(key, cmds);
708
+ }
709
+ for (const h of group.hooks) {
710
+ if (h && typeof h.command === 'string')
711
+ cmds.add(h.command);
712
+ }
713
+ }
714
+ }
715
+ const unwired = expected.filter((e) => !wiredByGroup.get(groupKey(e.event, e.matcher))?.has(e.command));
716
+ return { supported: true, settingsPath, expected: expected.length, unwired };
717
+ }
491
718
  /**
492
719
  * Check if a hook installed in a specific version matches central content.
493
720
  */
@@ -936,6 +1163,9 @@ function sweepOrphanShims(manifest) {
936
1163
  }
937
1164
  }
938
1165
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
1166
+ if (isAgentHardDeprecated(agentId)) {
1167
+ return { registered: [], errors: [] };
1168
+ }
939
1169
  const manifest = hookManifest || parseHookManifest();
940
1170
  if (Object.keys(manifest).length === 0) {
941
1171
  if (agentId === 'opencode') {
@@ -995,9 +1225,6 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
995
1225
  if (agentId === 'codex') {
996
1226
  return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
997
1227
  }
998
- if (agentId === 'gemini') {
999
- return registerHooksForGemini(versionHome, manifest, resolveScript, managedPrefixes);
1000
- }
1001
1228
  if (agentId === 'antigravity') {
1002
1229
  return registerHooksForAntigravity(versionHome, manifest, resolveScript, managedPrefixes);
1003
1230
  }
@@ -1183,16 +1410,6 @@ const ANTIGRAVITY_EVENT_MAP = {
1183
1410
  Stop: 'on_loop_stop',
1184
1411
  OnError: 'on_error',
1185
1412
  };
1186
- /**
1187
- * Gemini has no native UserPromptSubmit event — map it to BeforeAgent,
1188
- * the closest lifecycle phase that fires before the model sees the prompt.
1189
- * Note: gemini's BeforeAgent can only APPEND via additionalContext — it
1190
- * cannot replace the prompt. The hook script branches on caller to emit
1191
- * the correct protocol.
1192
- */
1193
- const GEMINI_EVENT_MAP = {
1194
- UserPromptSubmit: 'BeforeAgent',
1195
- };
1196
1413
  function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
1197
1414
  const registered = [];
1198
1415
  const errors = [];
@@ -1238,8 +1455,9 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1238
1455
  for (const group of eventEntries) {
1239
1456
  if (!group.hooks)
1240
1457
  continue;
1241
- group.hooks = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
1458
+ const managed = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
1242
1459
  !isStaleSiblingVersionCommand(h.command, currentVh));
1460
+ group.hooks = collapseVersionHookEntries(managed, versionHome);
1243
1461
  }
1244
1462
  }
1245
1463
  // Remove empty matcher groups left after cleanup
@@ -1387,12 +1605,18 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1387
1605
  if (resolved)
1388
1606
  currentManifestPaths.add(resolved);
1389
1607
  }
1608
+ // Identity of the version home being synced. Codex stores hooks in a shared
1609
+ // hooks.json per CODEX_HOME, but agents-cli registers version-scoped command
1610
+ // paths; without this, old version entries keep firing after upgrades.
1611
+ const currentVh = versionHomeIdentity(versionHome);
1390
1612
  // Remove stale entries from all event groups
1391
1613
  for (const eventGroups of Object.values(hooksFile.hooks)) {
1392
1614
  for (const group of eventGroups) {
1393
1615
  if (!group.hooks)
1394
1616
  continue;
1395
- group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
1617
+ const managed = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
1618
+ !isStaleSiblingVersionCommand(h.command, currentVh));
1619
+ group.hooks = collapseVersionHookEntries(managed, versionHome);
1396
1620
  }
1397
1621
  }
1398
1622
  for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
@@ -1436,7 +1660,8 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1436
1660
  group.hooks = [];
1437
1661
  }
1438
1662
  const existingIdx = group.hooks.findIndex((h) => h.command === commandPath);
1439
- const hookEntry = { type: 'command', command: commandPath, timeout };
1663
+ const eventTimeout = event === 'SessionEnd' ? Math.min(timeout, 3) : timeout;
1664
+ const hookEntry = { type: 'command', command: commandPath, timeout: eventTimeout };
1440
1665
  if (existingIdx >= 0) {
1441
1666
  group.hooks[existingIdx] = hookEntry;
1442
1667
  }
@@ -1529,81 +1754,6 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1529
1754
  }
1530
1755
  return { registered, errors };
1531
1756
  }
1532
- function registerHooksForGemini(versionHome, manifest, resolveScript, managedPrefixes) {
1533
- const registered = [];
1534
- const errors = [];
1535
- const settingsPath = path.join(versionHome, '.gemini', 'settings.json');
1536
- try {
1537
- updateGeminiSettings(settingsPath, (config) => {
1538
- setGeminiAutoUpdateDisabled(config);
1539
- if (!config.hooks || typeof config.hooks !== 'object') {
1540
- config.hooks = {};
1541
- }
1542
- const hooks = config.hooks;
1543
- const currentManifestPaths = new Set();
1544
- for (const [hookName, hookDef] of Object.entries(manifest)) {
1545
- if (!hookDef.events || hookDef.events.length === 0)
1546
- continue;
1547
- const resolved = resolveHookCommand(hookName, hookDef, resolveScript);
1548
- if (resolved)
1549
- currentManifestPaths.add(resolved);
1550
- }
1551
- for (const eventEntries of Object.values(hooks)) {
1552
- if (!Array.isArray(eventEntries))
1553
- continue;
1554
- for (const group of eventEntries) {
1555
- if (!group.hooks)
1556
- continue;
1557
- group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
1558
- }
1559
- }
1560
- for (const [event, eventEntries] of Object.entries(hooks)) {
1561
- if (!Array.isArray(eventEntries))
1562
- continue;
1563
- hooks[event] = eventEntries.filter((g) => g.hooks && g.hooks.length > 0);
1564
- }
1565
- for (const [name, hookDef] of Object.entries(manifest)) {
1566
- if (!hookDef.events || hookDef.events.length === 0)
1567
- continue;
1568
- const commandPath = resolveHookCommand(name, hookDef, resolveScript);
1569
- if (!commandPath) {
1570
- errors.push(`${name}: script not found in user or system hooks dir`);
1571
- continue;
1572
- }
1573
- const timeoutMs = (hookDef.timeout || 600) * 1000;
1574
- for (const event of hookDef.events) {
1575
- const geminiEvent = GEMINI_EVENT_MAP[event] ?? event;
1576
- if (!hooks[geminiEvent]) {
1577
- hooks[geminiEvent] = [];
1578
- }
1579
- const eventEntries = hooks[geminiEvent];
1580
- const matcher = hookDef.matcher || '';
1581
- let matcherGroup = eventEntries.find((e) => (e.matcher || '') === matcher);
1582
- if (!matcherGroup) {
1583
- matcherGroup = { matcher, hooks: [] };
1584
- eventEntries.push(matcherGroup);
1585
- }
1586
- if (!matcherGroup.hooks) {
1587
- matcherGroup.hooks = [];
1588
- }
1589
- const existingIdx = matcherGroup.hooks.findIndex((h) => h.command === commandPath);
1590
- const hookEntry = { name, type: 'command', command: commandPath, timeout: timeoutMs };
1591
- if (existingIdx >= 0) {
1592
- matcherGroup.hooks[existingIdx] = hookEntry;
1593
- }
1594
- else {
1595
- matcherGroup.hooks.push(hookEntry);
1596
- }
1597
- registered.push(`${name} -> ${geminiEvent}`);
1598
- }
1599
- }
1600
- });
1601
- }
1602
- catch (err) {
1603
- errors.push(`Failed to write gemini settings.json: ${err.message}`);
1604
- }
1605
- return { registered, errors };
1606
- }
1607
1757
  /**
1608
1758
  * Register hooks into antigravity's (agy) settings.json. Unlike gemini, agy uses
1609
1759
  * a flat per-event array of `{ command }` entries (no matcher groups). Events
@@ -11,6 +11,18 @@
11
11
  import type { Host } from './types.js';
12
12
  import { type HostTask } from './tasks.js';
13
13
  import { type HostCredentials } from './credentials.js';
14
+ /**
15
+ * Derive the remote directory to mirror from the local cwd, for a host run the
16
+ * caller gave no `--cwd`/`--remote-cwd`.
17
+ *
18
+ * Without this a `--host` run lands in the remote `$HOME`, so an agent launched
19
+ * from a repo starts with no project context and the user has to `cd` by hand.
20
+ * Only a cwd under the LOCAL home is mirrored — that is the part with a
21
+ * meaningful remote analogue (`~/src/x` re-roots onto the remote home). A path
22
+ * outside home returns undefined: `/opt/thing` on this box says nothing about
23
+ * the target's filesystem, so the run keeps the remote home.
24
+ */
25
+ export declare function deriveMirroredCwd(localCwd: string): string | undefined;
14
26
  /**
15
27
  * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
16
28
  *
@@ -18,8 +30,19 @@ import { type HostCredentials } from './credentials.js';
18
30
  * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
19
31
  * for that segment — the remote login shell expands it — and shell-quote the
20
32
  * remainder. Any other path (absolute or relative) is quoted verbatim.
33
+ *
34
+ * `mirror` marks a directory the caller DERIVED from the local cwd rather than
35
+ * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
36
+ * the same home-relative path on both boxes is the common fleet layout, so
37
+ * mirroring lands the remote agent in the project instead of `$HOME`. It is a
38
+ * best-effort mirror by definition — the host may simply not have that checkout
39
+ * — so a missing directory falls back to the remote home instead of failing the
40
+ * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
41
+ * directory, so a missing one must surface as a `cd` error.
21
42
  */
22
- export declare function remoteCdPrefix(remoteCwd?: string): string;
43
+ export declare function remoteCdPrefix(remoteCwd?: string, opts?: {
44
+ mirror?: boolean;
45
+ }): string;
23
46
  /**
24
47
  * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
25
48
  * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
@@ -103,6 +126,8 @@ export interface DispatchOptions {
103
126
  /** Native-CLI passthrough (everything after `--`), appended last. */
104
127
  passthroughArgs?: string[];
105
128
  remoteCwd?: string;
129
+ /** `remoteCwd` was derived from the local cwd — mirror it, don't fail on it. */
130
+ mirrorCwd?: boolean;
106
131
  /**
107
132
  * Force the remote run's NEW session to use this exact id (Claude only, via
108
133
  * `agents run --session-id`). Captured on the task record so the run is
@@ -165,6 +190,8 @@ export interface InteractiveDispatchOptions {
165
190
  /** Route through the Agent Client Protocol. */
166
191
  acp?: boolean;
167
192
  remoteCwd?: string;
193
+ /** `remoteCwd` was derived from the local cwd — mirror it, don't fail on it. */
194
+ mirrorCwd?: boolean;
168
195
  sessionId?: string;
169
196
  name?: string;
170
197
  resume?: string;
@@ -19,6 +19,7 @@ import { saveTask, updateTask, terminalPatch } from './tasks.js';
19
19
  import { followHostTask } from './progress.js';
20
20
  import { wrapHostCommandWithCredentials } from './credentials.js';
21
21
  import { hostKeyCheckingOpts } from '../devices/known-hosts.js';
22
+ import { toRemotePortable } from '../project-root.js';
22
23
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
23
24
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
24
25
  // injection-safe to interpolate unquoted into remote commands.
@@ -39,6 +40,21 @@ function homeRemainder(p) {
39
40
  return p.slice(6);
40
41
  return null;
41
42
  }
43
+ /**
44
+ * Derive the remote directory to mirror from the local cwd, for a host run the
45
+ * caller gave no `--cwd`/`--remote-cwd`.
46
+ *
47
+ * Without this a `--host` run lands in the remote `$HOME`, so an agent launched
48
+ * from a repo starts with no project context and the user has to `cd` by hand.
49
+ * Only a cwd under the LOCAL home is mirrored — that is the part with a
50
+ * meaningful remote analogue (`~/src/x` re-roots onto the remote home). A path
51
+ * outside home returns undefined: `/opt/thing` on this box says nothing about
52
+ * the target's filesystem, so the run keeps the remote home.
53
+ */
54
+ export function deriveMirroredCwd(localCwd) {
55
+ const portable = toRemotePortable(localCwd);
56
+ return homeRemainder(portable) === null ? undefined : portable;
57
+ }
42
58
  /**
43
59
  * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
44
60
  *
@@ -46,15 +62,26 @@ function homeRemainder(p) {
46
62
  * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
47
63
  * for that segment — the remote login shell expands it — and shell-quote the
48
64
  * remainder. Any other path (absolute or relative) is quoted verbatim.
65
+ *
66
+ * `mirror` marks a directory the caller DERIVED from the local cwd rather than
67
+ * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
68
+ * the same home-relative path on both boxes is the common fleet layout, so
69
+ * mirroring lands the remote agent in the project instead of `$HOME`. It is a
70
+ * best-effort mirror by definition — the host may simply not have that checkout
71
+ * — so a missing directory falls back to the remote home instead of failing the
72
+ * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
73
+ * directory, so a missing one must surface as a `cd` error.
49
74
  */
50
- export function remoteCdPrefix(remoteCwd) {
75
+ export function remoteCdPrefix(remoteCwd, opts = {}) {
51
76
  if (!remoteCwd)
52
77
  return '';
53
78
  const rest = homeRemainder(remoteCwd);
54
79
  if (rest === '')
55
80
  return 'cd "$HOME" && ';
56
- if (rest !== null)
57
- return `cd "$HOME"/${shellQuote(rest)} && `;
81
+ if (rest !== null) {
82
+ const dir = `"$HOME"/${shellQuote(rest)}`;
83
+ return opts.mirror ? `{ cd ${dir} || cd "$HOME"; } && ` : `cd ${dir} && `;
84
+ }
58
85
  return `cd ${shellQuote(remoteCwd)} && `;
59
86
  }
60
87
  /**
@@ -190,7 +217,7 @@ async function launchDetached(host, target, opts) {
190
217
  // resolved actor provenance first so the detached remote run inherits it
191
218
  // instead of re-resolving from this box's SSH_CONNECTION (RUSH-2028).
192
219
  const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
193
- const cwd = remoteCdPrefix(opts.remoteCwd);
220
+ const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
194
221
  const actorExports = posixEnvExports(withActorEnv());
195
222
  const prelude = actorExports ? `${actorExports}; ` : '';
196
223
  let inner = `${prelude}${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
@@ -385,7 +412,7 @@ export async function runInteractiveOnHost(host, opts) {
385
412
  for (const w of warnings)
386
413
  process.stderr.write(`[hosts] warning: ${w}\n`);
387
414
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
388
- const cwd = remoteCdPrefix(opts.remoteCwd);
415
+ const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
389
416
  // Forward actor provenance so the interactive remote run inherits it rather
390
417
  // than re-resolving from this box's SSH_CONNECTION (RUSH-2028).
391
418
  const actorExports = posixEnvExports(withActorEnv());
@@ -413,6 +440,7 @@ export async function dispatchToHost(host, opts) {
413
440
  return launchDetached(host, target, {
414
441
  forwardedArgs: buildRunForwardedArgs(opts),
415
442
  remoteCwd: opts.remoteCwd,
443
+ mirrorCwd: opts.mirrorCwd,
416
444
  follow: opts.follow,
417
445
  timeoutMs: opts.timeoutMs,
418
446
  agentLabel: opts.agent,
@@ -12,8 +12,8 @@
12
12
  /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
13
  export function addHostOption(cmd) {
14
14
  return cmd
15
- .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
16
- .option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
15
+ .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, user@host, or `all` to fan out across every registered device. See `agents devices` / `agents hosts`.')
16
+ .option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`), or `all` to run it across the whole fleet.')
17
17
  .option('--remote-cwd <dir>', "Working directory on the host for --host runs. Resolves on the REMOTE host — pass a '$HOME'-relative path (single-quoted so your local shell doesn't expand it) or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you). No effect on 'teams add'.")
18
18
  .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
19
19
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
@@ -16,8 +16,28 @@
16
16
  * table or, when `--host`/`--device` is present, exits with a clear
17
17
  * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
+ import { type DeviceRegistry } from '../devices/registry.js';
20
+ import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
21
+ /** Per-command remote behaviour. Absence from this map = not host-routable here. */
22
+ interface RemoteSpec {
23
+ /** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
24
+ nonInteractive?: string[];
25
+ }
19
26
  /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
20
27
  export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
28
+ /** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
29
+ export interface FleetPassthroughOptions {
30
+ /** Override the device registry loader (tests). */
31
+ loadDevices?: () => Promise<DeviceRegistry>;
32
+ /** Override the per-device runner (tests). Defaults to `runOnDevice`. */
33
+ runner?: typeof runOnDevice;
34
+ /** Override the local runner for the self device (tests). Defaults to `runLocalCommand`. */
35
+ localRunner?: typeof runLocalCommand;
36
+ /** Override this machine's id (tests). Defaults to `machineId()`. */
37
+ self?: string;
38
+ }
39
+ /** Run `agents <command> …` across every registered device and render the roster. */
40
+ export declare function runFleetPassthrough(command: string, allArgs: string[], spec: RemoteSpec, opts?: FleetPassthroughOptions): Promise<boolean>;
21
41
  /**
22
42
  * Route `agents <command> … --host <name>` to a remote if the command is
23
43
  * host-routable and a `--host` (or its `--device` alias) was given. Returns
@@ -30,4 +50,5 @@ export declare function flagValue(args: string[], long: string, short?: string):
30
50
  * @param command the resolved subcommand name (`process.argv`'s first non-flag).
31
51
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
32
52
  */
33
- export declare function maybeRunOnHost(command: string, allArgs: string[]): Promise<boolean>;
53
+ export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
54
+ export {};