@phnx-labs/agents-cli 1.20.78 → 1.20.82

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 (176) hide show
  1. package/CHANGELOG.md +487 -0
  2. package/README.md +25 -25
  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 +65 -0
  12. package/dist/commands/doctor.js +575 -31
  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/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +187 -11
  29. package/dist/commands/routines.js +12 -2
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +100 -32
  33. package/dist/commands/sessions-inject.d.ts +7 -4
  34. package/dist/commands/sessions-inject.js +18 -18
  35. package/dist/commands/sessions-picker.js +32 -3
  36. package/dist/commands/sessions-resume.d.ts +16 -0
  37. package/dist/commands/sessions-resume.js +14 -13
  38. package/dist/commands/sessions.d.ts +31 -0
  39. package/dist/commands/sessions.js +141 -16
  40. package/dist/commands/set.d.ts +15 -0
  41. package/dist/commands/set.js +79 -0
  42. package/dist/commands/setup-fleet.d.ts +21 -0
  43. package/dist/commands/setup-fleet.js +201 -0
  44. package/dist/commands/setup-secrets.d.ts +20 -0
  45. package/dist/commands/setup-secrets.js +223 -0
  46. package/dist/commands/setup.js +17 -3
  47. package/dist/commands/skills.js +1 -1
  48. package/dist/commands/sync.js +7 -2
  49. package/dist/commands/teams-picker.js +0 -1
  50. package/dist/commands/teams.d.ts +33 -1
  51. package/dist/commands/teams.js +208 -44
  52. package/dist/commands/versions.js +10 -5
  53. package/dist/commands/view.d.ts +1 -0
  54. package/dist/commands/view.js +11 -0
  55. package/dist/index.js +5 -8
  56. package/dist/lib/acp/harnesses.js +0 -7
  57. package/dist/lib/activity.d.ts +156 -0
  58. package/dist/lib/activity.js +282 -0
  59. package/dist/lib/agents.d.ts +9 -2
  60. package/dist/lib/agents.js +72 -63
  61. package/dist/lib/browser/service.js +3 -0
  62. package/dist/lib/browser/types.d.ts +7 -0
  63. package/dist/lib/capabilities.js +6 -2
  64. package/dist/lib/crabbox/lease.js +2 -2
  65. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  66. package/dist/lib/crabbox/setup-copy.js +4 -4
  67. package/dist/lib/daemon.d.ts +18 -0
  68. package/dist/lib/daemon.js +47 -6
  69. package/dist/lib/devices/fleet.d.ts +1 -1
  70. package/dist/lib/devices/fleet.js +1 -1
  71. package/dist/lib/doctor-diff.d.ts +20 -0
  72. package/dist/lib/doctor-diff.js +6 -1
  73. package/dist/lib/drift.d.ts +31 -11
  74. package/dist/lib/drift.js +58 -7
  75. package/dist/lib/events.d.ts +8 -1
  76. package/dist/lib/events.js +10 -1
  77. package/dist/lib/exec.js +35 -8
  78. package/dist/lib/feed-post.d.ts +28 -1
  79. package/dist/lib/feed-post.js +110 -2
  80. package/dist/lib/fleet/apply.js +8 -1
  81. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  82. package/dist/lib/fleet/auth-sync.js +25 -11
  83. package/dist/lib/fleet/remote-login.js +5 -0
  84. package/dist/lib/git.d.ts +14 -14
  85. package/dist/lib/git.js +41 -44
  86. package/dist/lib/hooks.d.ts +42 -0
  87. package/dist/lib/hooks.js +137 -93
  88. package/dist/lib/hosts/dispatch.d.ts +28 -1
  89. package/dist/lib/hosts/dispatch.js +33 -5
  90. package/dist/lib/hosts/option.js +2 -2
  91. package/dist/lib/hosts/passthrough.d.ts +22 -1
  92. package/dist/lib/hosts/passthrough.js +238 -13
  93. package/dist/lib/hosts/run-target.d.ts +2 -0
  94. package/dist/lib/hosts/run-target.js +1 -0
  95. package/dist/lib/hq/floor.d.ts +1 -1
  96. package/dist/lib/hq/floor.js +6 -1
  97. package/dist/lib/mcp.js +3 -71
  98. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  99. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  100. package/dist/lib/menubar/notify-desktop.js +41 -5
  101. package/dist/lib/merged-resources.d.ts +11 -0
  102. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  103. package/dist/lib/permissions.d.ts +0 -32
  104. package/dist/lib/permissions.js +4 -157
  105. package/dist/lib/picker.js +1 -1
  106. package/dist/lib/platform/process.d.ts +16 -5
  107. package/dist/lib/platform/process.js +54 -0
  108. package/dist/lib/plugins.d.ts +0 -8
  109. package/dist/lib/plugins.js +4 -110
  110. package/dist/lib/project-resources.js +4 -1
  111. package/dist/lib/refresh.d.ts +5 -3
  112. package/dist/lib/refresh.js +7 -5
  113. package/dist/lib/resources/commands.js +4 -1
  114. package/dist/lib/resources/mcp.js +0 -4
  115. package/dist/lib/resources/permissions.d.ts +1 -1
  116. package/dist/lib/resources/permissions.js +1 -5
  117. package/dist/lib/resources/rules.js +4 -1
  118. package/dist/lib/resources/skills.js +4 -1
  119. package/dist/lib/resources/subagents.js +4 -0
  120. package/dist/lib/resources/types.d.ts +1 -1
  121. package/dist/lib/routines.d.ts +20 -0
  122. package/dist/lib/routines.js +6 -0
  123. package/dist/lib/run-defaults.d.ts +1 -0
  124. package/dist/lib/run-defaults.js +9 -0
  125. package/dist/lib/runner.d.ts +0 -12
  126. package/dist/lib/runner.js +49 -10
  127. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  129. package/dist/lib/secrets/agent.d.ts +16 -0
  130. package/dist/lib/secrets/agent.js +105 -3
  131. package/dist/lib/secrets/audit.d.ts +46 -0
  132. package/dist/lib/secrets/audit.js +56 -0
  133. package/dist/lib/secrets/bundles.d.ts +11 -8
  134. package/dist/lib/secrets/bundles.js +31 -23
  135. package/dist/lib/secrets/index.d.ts +2 -1
  136. package/dist/lib/secrets/index.js +8 -2
  137. package/dist/lib/secrets/remote.d.ts +75 -2
  138. package/dist/lib/secrets/remote.js +139 -5
  139. package/dist/lib/secrets/sync.js +3 -3
  140. package/dist/lib/session/active.d.ts +110 -26
  141. package/dist/lib/session/active.js +161 -50
  142. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  143. package/dist/lib/session/actor-sidecar.js +101 -0
  144. package/dist/lib/session/db.d.ts +4 -1
  145. package/dist/lib/session/db.js +65 -8
  146. package/dist/lib/session/discover.d.ts +4 -0
  147. package/dist/lib/session/discover.js +13 -0
  148. package/dist/lib/session/hook-sessions.d.ts +9 -0
  149. package/dist/lib/session/hook-sessions.js +57 -8
  150. package/dist/lib/session/pid-registry.d.ts +13 -0
  151. package/dist/lib/session/render.d.ts +2 -0
  152. package/dist/lib/session/render.js +1 -1
  153. package/dist/lib/session/types.d.ts +16 -0
  154. package/dist/lib/staleness/detectors/commands.js +2 -2
  155. package/dist/lib/staleness/detectors/permissions.js +1 -45
  156. package/dist/lib/staleness/writers/commands.js +4 -5
  157. package/dist/lib/staleness/writers/hooks.js +1 -1
  158. package/dist/lib/startup/command-registry.d.ts +1 -2
  159. package/dist/lib/startup/command-registry.js +2 -4
  160. package/dist/lib/subagents-registry.js +5 -12
  161. package/dist/lib/subagents.d.ts +0 -10
  162. package/dist/lib/subagents.js +0 -12
  163. package/dist/lib/teams/agents.d.ts +0 -2
  164. package/dist/lib/teams/agents.js +10 -27
  165. package/dist/lib/teams/index.d.ts +1 -1
  166. package/dist/lib/teams/index.js +1 -1
  167. package/dist/lib/types.d.ts +17 -9
  168. package/dist/lib/usage.d.ts +28 -5
  169. package/dist/lib/usage.js +271 -8
  170. package/dist/lib/versions.js +7 -1
  171. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  172. package/dist/lib/watchdog/watchdog.js +25 -13
  173. package/package.json +1 -2
  174. package/dist/commands/check.d.ts +0 -15
  175. package/dist/commands/check.js +0 -180
  176. package/dist/commands/resources.d.ts +0 -5
@@ -23,7 +23,6 @@ import * as path from 'path';
23
23
  export const FLEET_AUTH_FILES = {
24
24
  claude: [{ rel: '.claude/.credentials.json', mode: 0o600 }],
25
25
  codex: [{ rel: '.codex/auth.json', mode: 0o600 }],
26
- gemini: [{ rel: '.gemini/oauth_creds.json', mode: 0o600 }],
27
26
  grok: [{ rel: '.grok/auth.json', mode: 0o600 }],
28
27
  kimi: [{ rel: '.kimi-code/credentials/kimi-code.json', mode: 0o600 }],
29
28
  opencode: [{ rel: '.local/share/opencode/auth.json', mode: 0o600 }],
@@ -35,9 +34,30 @@ export const FLEET_AUTH_FILES = {
35
34
  };
36
35
  /** Agents whose macOS credentials live in the ACL-bound login keychain. */
37
36
  export const KEYCHAIN_BOUND_ON_MAC = new Set(['claude', 'antigravity']);
37
+ /**
38
+ * Agents whose OAuth credentials rely on single-use refresh tokens that rotate
39
+ * server-side on every exchange. Copying these credential files across machines
40
+ * is fatal: the first refresh on any box invalidates every other holder's token,
41
+ * collapsing the fleet to a single working login (droid/WorkOS collapsed 10 boxes
42
+ * to 1 overnight — RUSH-1958). Add any newly-discovered single-use-rotation
43
+ * harness here; the predicate below is the one place the propagation decision is
44
+ * made. See also `remote-login.ts` and `usage.ts` for the per-machine-login policy.
45
+ */
46
+ export const SINGLE_USE_ROTATING_REFRESH_AGENTS = new Set(['droid']);
47
+ /** True when `agent`'s portable credential file(s) are safe to copy between
48
+ * machines. Single-use rotating refresh tokens are never safe; keychain-bound
49
+ * tokens are handled separately by the caller via {@link KEYCHAIN_BOUND_ON_MAC}. */
50
+ export function isCredentialSafeToPropagate(agent) {
51
+ return !SINGLE_USE_ROTATING_REFRESH_AGENTS.has(agent);
52
+ }
53
+ /** True when the agent stores credentials in portable files we can read. This
54
+ * does NOT mean it is safe to propagate — check {@link isCredentialSafeToPropagate}. */
55
+ export function hasPortableAuthFiles(agent) {
56
+ return agent in FLEET_AUTH_FILES;
57
+ }
38
58
  /** Which agents `apply` can propagate auth for at all. */
39
59
  export function isPropagatableAgent(agent) {
40
- return agent in FLEET_AUTH_FILES;
60
+ return hasPortableAuthFiles(agent) && isCredentialSafeToPropagate(agent);
41
61
  }
42
62
  /** The success-file for an agent — the primary portable credential (first spec). */
43
63
  function successFileFor(agent) {
@@ -107,14 +127,6 @@ export const FLEET_LOGIN_FLOWS = {
107
127
  flowType: 'loopback',
108
128
  successFile: successFileFor('antigravity'),
109
129
  },
110
- // gemini: bare `gemini`, Google login on launch. Not characterized as a
111
- // device-code flow (typically loopback) and no captured pattern — mark unknown
112
- // so it is flagged non-remotable rather than mis-driven.
113
- gemini: {
114
- loginCommand: 'gemini',
115
- flowType: 'unknown',
116
- successFile: successFileFor('gemini'),
117
- },
118
130
  // opencode: `opencode auth login`. No captured pattern — unknown/non-remotable.
119
131
  opencode: {
120
132
  loginCommand: 'opencode auth login',
@@ -144,7 +156,9 @@ export function snapshotAuth(agents, opts) {
144
156
  for (const agent of agents) {
145
157
  const specs = FLEET_AUTH_FILES[agent];
146
158
  if (!specs)
147
- continue; // not propagatable — caller surfaces separately if desired
159
+ continue; // no portable file — caller surfaces separately if desired
160
+ if (!isCredentialSafeToPropagate(agent))
161
+ continue; // single-use rotating refresh tokens are never copied
148
162
  if (opts.platform === 'darwin' && KEYCHAIN_BOUND_ON_MAC.has(agent)) {
149
163
  bound.push(agent);
150
164
  continue;
@@ -9,6 +9,11 @@
9
9
  * each verification URL + user code, and surfacing them all in ONE local browser
10
10
  * page so the human enters codes back-to-back instead of babysitting N terminals.
11
11
  *
12
+ * The propagation gate lives in `auth-sync.ts`: {@link isCredentialSafeToPropagate}
13
+ * and {@link SINGLE_USE_ROTATING_REFRESH_AGENTS} are the single place where the
14
+ * "is this credential safe to copy?" decision is made, shared by `agents apply`
15
+ * and any other propagation path.
16
+ *
12
17
  * Layering — pure vs I/O, so the valuable logic is unit-testable without SSH:
13
18
  * - `scrapeLogin` / `classifyLoginFlow` / `selectLoginTargets` /
14
19
  * `buildRemoteLoginSshCommand` / `buildDashboardHtml` are PURE.
package/dist/lib/git.d.ts CHANGED
@@ -248,20 +248,6 @@ export declare function displayHomePath(dir: string): string;
248
248
  * branch reconciles instead of failing with "Need to specify how to reconcile
249
249
  * divergent branches".
250
250
  */
251
- /**
252
- * Auto-commit the machine's OWN generated per-device meta file
253
- * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
254
- *
255
- * That file is committed + synced (so every machine can introspect every other
256
- * machine's pins), but each box rewrites its own copy whenever a pin changes —
257
- * leaving the tree perpetually dirty and wedging `agents repo pull` (which
258
- * refuses a dirty tree). This durably commits just that one path (explicit
259
- * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
260
- * untouched and still (correctly) block the pull. No-op when the meta path isn't
261
- * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
262
- * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
263
- */
264
- export declare function commitOwnDeviceMeta(dir: string, metaAbs?: string): Promise<string | null>;
265
251
  export declare function pullRepo(dir: string): Promise<{
266
252
  success: boolean;
267
253
  commit: string;
@@ -341,3 +327,17 @@ export declare function tryAutoPull(dir: string): Promise<{
341
327
  pulled: boolean;
342
328
  error?: string;
343
329
  }>;
330
+ /**
331
+ * How many commits `dir`'s checked-out branch is behind its upstream, read from
332
+ * the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
333
+ * dir is not a git repo, has no upstream configured, or git errors.
334
+ *
335
+ * Used by `agents doctor` to flag a source layer (`~/.agents`, `~/.agents/.system`)
336
+ * that is reconciled against stale truth. Staleness relative to origin is a
337
+ * background auto-pull concern; this surfaces the same fact synchronously in the
338
+ * per-version verdict.
339
+ */
340
+ export declare function commitsBehindUpstream(dir: string): {
341
+ behind: number;
342
+ branch: string;
343
+ } | null;
package/dist/lib/git.js CHANGED
@@ -11,7 +11,7 @@ import * as fs from 'fs';
11
11
  import * as os from 'os';
12
12
  import * as path from 'path';
13
13
  import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
14
- import { getPackageLocalPath, getDeviceMetaPath } from './state.js';
14
+ import { getPackageLocalPath } from './state.js';
15
15
  import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
16
16
  /**
17
17
  * Validate that a clone/pull source uses a safe git transport before it is
@@ -803,48 +803,13 @@ export function displayHomePath(dir) {
803
803
  * branch reconciles instead of failing with "Need to specify how to reconcile
804
804
  * divergent branches".
805
805
  */
806
- /**
807
- * Auto-commit the machine's OWN generated per-device meta file
808
- * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
809
- *
810
- * That file is committed + synced (so every machine can introspect every other
811
- * machine's pins), but each box rewrites its own copy whenever a pin changes —
812
- * leaving the tree perpetually dirty and wedging `agents repo pull` (which
813
- * refuses a dirty tree). This durably commits just that one path (explicit
814
- * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
815
- * untouched and still (correctly) block the pull. No-op when the meta path isn't
816
- * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
817
- * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
818
- */
819
- export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
820
- const resolvedDir = path.resolve(dir);
821
- const resolvedMeta = path.resolve(metaAbs);
822
- if (resolvedMeta !== resolvedDir && !resolvedMeta.startsWith(resolvedDir + path.sep)) {
823
- return null; // meta lives outside this repo — not ours to commit here
824
- }
825
- const rel = path.relative(resolvedDir, resolvedMeta).split(path.sep).join('/');
826
- try {
827
- const git = simpleGit(dir);
828
- const status = await git.status();
829
- const dirty = status.files.some((f) => f.path === rel);
830
- if (!dirty)
831
- return null;
832
- await git.add([rel]);
833
- const machine = path.basename(path.dirname(resolvedMeta));
834
- await git.commit(`chore(devices): snapshot ${machine} agent pins`, [rel]);
835
- return rel;
836
- }
837
- catch {
838
- return null; // best-effort — never let this block the pull path
839
- }
840
- }
841
806
  export async function pullRepo(dir) {
842
807
  try {
843
808
  const git = simpleGit(dir);
844
809
  // A rebase left in progress by an earlier run must be reported as itself.
845
810
  // Without this the dirty-tree guard below claims "Blocked by local changes",
846
811
  // which is both wrong and actively harmful advice mid-rebase on a detached
847
- // HEAD. Checked BEFORE commitOwnDeviceMeta so we never commit into one.
812
+ // HEAD.
848
813
  // Ask git where the state dirs live rather than assuming `<dir>/.git/` is a
849
814
  // directory. In a worktree `.git` is a FILE containing `gitdir: <path>`, so
850
815
  // path.join(dir, '.git', 'rebase-merge') can never exist and the check would
@@ -874,9 +839,6 @@ export async function pullRepo(dir) {
874
839
  ` git rebase --abort # to discard the attempt`,
875
840
  };
876
841
  }
877
- // Commit this machine's own device-meta first so a per-machine pin change
878
- // never wedges the pull. Genuine edits elsewhere still block below.
879
- await commitOwnDeviceMeta(dir);
880
842
  const status = await git.status();
881
843
  if (!status.isClean()) {
882
844
  return {
@@ -934,10 +896,8 @@ export async function pullRepo(dir) {
934
896
  }
935
897
  try {
936
898
  // Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
937
- // not, so a single local commit including the one commitOwnDeviceMeta
938
- // makes just above permanently wedged the pull with nothing actually in
939
- // conflict. Every device carries its own devices/<host>/ path, so those
940
- // replay cleanly. Matches syncRepoGit (below) and this function's own doc.
899
+ // not, so a single local commit permanently wedged the pull with nothing
900
+ // actually in conflict. Matches syncRepoGit (below) and this function's doc.
941
901
  //
942
902
  // Pull the RESOLVED tracking ref, not `branch`: when the local branch has
943
903
  // no tracking config the block above falls back to origin's default head,
@@ -1211,3 +1171,40 @@ export async function tryAutoPull(dir) {
1211
1171
  return { pulled: false, error: err.message };
1212
1172
  }
1213
1173
  }
1174
+ /**
1175
+ * How many commits `dir`'s checked-out branch is behind its upstream, read from
1176
+ * the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
1177
+ * dir is not a git repo, has no upstream configured, or git errors.
1178
+ *
1179
+ * Used by `agents doctor` to flag a source layer (`~/.agents`, `~/.agents/.system`)
1180
+ * that is reconciled against stale truth. Staleness relative to origin is a
1181
+ * background auto-pull concern; this surfaces the same fact synchronously in the
1182
+ * per-version verdict.
1183
+ */
1184
+ export function commitsBehindUpstream(dir) {
1185
+ if (!isGitRepo(dir))
1186
+ return null;
1187
+ const run = (args) => {
1188
+ try {
1189
+ return execFileSync('git', ['-C', dir, ...args], {
1190
+ stdio: ['ignore', 'pipe', 'ignore'],
1191
+ encoding: 'utf8',
1192
+ }).trim();
1193
+ }
1194
+ catch {
1195
+ return null;
1196
+ }
1197
+ };
1198
+ // Name of the upstream ref (e.g. `origin/main`) for the human-readable message.
1199
+ const branch = run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
1200
+ if (!branch)
1201
+ return null;
1202
+ // `--count HEAD..@{upstream}` = commits on upstream not yet in HEAD = behind.
1203
+ const raw = run(['rev-list', '--count', 'HEAD..@{upstream}']);
1204
+ if (raw === null)
1205
+ return null;
1206
+ const behind = parseInt(raw, 10);
1207
+ if (!Number.isFinite(behind))
1208
+ return null;
1209
+ return { behind, branch };
1210
+ }
@@ -59,6 +59,48 @@ export declare function getVersionHooksDir(agent: AgentId, version: string): str
59
59
  * List hook entries in a specific version home.
60
60
  */
61
61
  export declare function listHooksInVersionHome(agent: AgentId, version: string): HookEntry[];
62
+ export interface HookWiringIssue {
63
+ /** Hook name (script basename minus extension). */
64
+ name: string;
65
+ /** Native lifecycle event the hook should be wired to (Stop, PreToolUse, …). */
66
+ event: string;
67
+ /** Matcher group the hook belongs to under `event` (`''` = the catch-all
68
+ * group). Real hooks scope by matcher — ask-user-question-guard=AskUserQuestion,
69
+ * user-message-guard=Bash — so wiring is verified per (event, matcher). */
70
+ matcher: string;
71
+ /** The command settings.json should reference for this hook under `event`. */
72
+ command: string;
73
+ }
74
+ export interface HookWiringReport {
75
+ /** Whether this agent's hook config format is understood by the inspector. */
76
+ supported: boolean;
77
+ /** Absolute path of the settings file inspected (when supported). */
78
+ settingsPath?: string;
79
+ /** Number of hooks the manifest says should be wired for this version. */
80
+ expected?: number;
81
+ /** settings.json does not exist — nothing declared can be wired. */
82
+ settingsMissing?: boolean;
83
+ /** settings.json exists but is not valid JSON — wiring can't be verified. */
84
+ settingsUnparseable?: boolean;
85
+ /** Hooks whose file is present/resolvable but that are NOT referenced in the
86
+ * event array settings.json should carry them in. */
87
+ unwired: HookWiringIssue[];
88
+ }
89
+ /**
90
+ * Verify that every hook the manifest says should be wired for a (claude|droid)
91
+ * version is actually REFERENCED in that version's native settings.json — not
92
+ * merely present as a file on disk.
93
+ *
94
+ * `agents doctor` compares hook FILES against source (see diffHooks in
95
+ * doctor-diff.ts) but never checks the wiring, so a hook whose script is
96
+ * byte-identical to source yet missing from settings.json's PreToolUse/Stop/…
97
+ * array reads as "ok" while it never fires. This closes that blind spot.
98
+ *
99
+ * Read-only by construction: it mirrors registerHooksForClaude's command
100
+ * resolution WITHOUT the shim-generation / chmod side effects that
101
+ * resolveHookCommand performs, so it never mutates the version home.
102
+ */
103
+ export declare function checkVersionHookWiring(agent: AgentId, version: string): HookWiringReport;
62
104
  export interface VersionHookDiff {
63
105
  agent: AgentId;
64
106
  version: string;
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(); }
@@ -148,7 +147,7 @@ function isStaleSiblingVersionCommand(command, current) {
148
147
  return id !== null && id.agent === current.agent && id.version !== current.version;
149
148
  }
150
149
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
151
- import { generateHookShim, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
150
+ import { generateHookShim, getHookShimPath, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
152
151
  import { getHookShimsDir } from './state.js';
153
152
  /**
154
153
  * Resolve the command path to register for a hook.
@@ -488,6 +487,130 @@ export function getVersionHooksDir(agent, version) {
488
487
  export function listHooksInVersionHome(agent, version) {
489
488
  return listHookEntriesFromDir(getVersionHooksDir(agent, version));
490
489
  }
490
+ // ─── wiring inspection (settings.json family: claude, droid) ──────────────────
491
+ /**
492
+ * Agents whose hooks register through {@link registerHooksForClaude} — a native
493
+ * settings.json shaped `hooks[event] = [{ matcher, hooks: [{ command }] }]`, with
494
+ * no event renaming. These are the only agents this read-only wiring inspector
495
+ * understands; every other harness uses a divergent config format and/or event
496
+ * map (Gemini/Antigravity settings.json variants, Codex config.toml, the OpenCode
497
+ * plugin, …), so it reports them unsupported rather than risk a false verdict.
498
+ */
499
+ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid'];
500
+ /**
501
+ * Verify that every hook the manifest says should be wired for a (claude|droid)
502
+ * version is actually REFERENCED in that version's native settings.json — not
503
+ * merely present as a file on disk.
504
+ *
505
+ * `agents doctor` compares hook FILES against source (see diffHooks in
506
+ * doctor-diff.ts) but never checks the wiring, so a hook whose script is
507
+ * byte-identical to source yet missing from settings.json's PreToolUse/Stop/…
508
+ * array reads as "ok" while it never fires. This closes that blind spot.
509
+ *
510
+ * Read-only by construction: it mirrors registerHooksForClaude's command
511
+ * resolution WITHOUT the shim-generation / chmod side effects that
512
+ * resolveHookCommand performs, so it never mutates the version home.
513
+ */
514
+ export function checkVersionHookWiring(agent, version) {
515
+ if (!AGENTS[agent].supportsHooks || !SETTINGS_JSON_HOOK_FAMILY.includes(agent)) {
516
+ return { supported: false, unwired: [] };
517
+ }
518
+ const versionHome = getVersionHomePath(agent, version);
519
+ const settingsPath = path.join(versionHome, agentConfigDirName(agent), 'settings.json');
520
+ const localHooksDir = getVersionHooksDir(agent, version);
521
+ // Resolve ONLY to a script that was actually synced for THIS agent+version: the
522
+ // copy in the version home hooks dir, or an absolute subrule-dir path (those are
523
+ // registered in place, never copied). Deliberately NO central-source fallback —
524
+ // sync wires `selectHookManifest(parseHookManifest(), hooksToSync)` where
525
+ // hooksToSync is the per-agent selected set (versions.ts), so a hook the manifest
526
+ // declares but that was never synced into THIS version home (e.g. a claude-scoped
527
+ // hook viewed for droid) must not be expected here. A missing-but-declared hook
528
+ // is a FILE gap that diffHooks reports as `missing`, not a wiring gap.
529
+ // No ensureExecutable — that chmods, and this path must not touch disk.
530
+ const resolveScript = (script) => {
531
+ if (path.isAbsolute(script) && fs.existsSync(script))
532
+ return script;
533
+ return resolveContainedHookPath(localHooksDir, script);
534
+ };
535
+ // Read-only mirror of resolveHookCommand — same command string, no shim write.
536
+ const expectedCommand = (name, hookDef) => {
537
+ const scriptPath = resolveScript(hookDef.script);
538
+ if (!scriptPath)
539
+ return null;
540
+ if (!isValidHookShimName(name))
541
+ return null;
542
+ const cache = parseCacheConfig(hookDef.cache);
543
+ const hasMatches = hookDef.matches != null && Object.keys(hookDef.matches).length > 0;
544
+ if (!cache && !hasMatches)
545
+ return toPortableCommand(scriptPath);
546
+ return toPortableCommand(getHookShimPath(name));
547
+ };
548
+ const manifest = parseHookManifest({ warn: false });
549
+ const expected = [];
550
+ for (const [name, hookDef] of Object.entries(manifest)) {
551
+ if (!hookDef.events || hookDef.events.length === 0)
552
+ continue;
553
+ const command = expectedCommand(name, hookDef);
554
+ if (!command)
555
+ continue; // script unresolved — a file gap, reported by diffHooks
556
+ // Mirror registerHooksForClaude: a hook registers under the matcher group
557
+ // `hookDef.matcher || ''` for each of its events.
558
+ const matcher = hookDef.matcher || '';
559
+ for (const event of hookDef.events)
560
+ expected.push({ name, event, matcher, command });
561
+ }
562
+ if (!fs.existsSync(settingsPath)) {
563
+ return {
564
+ supported: true,
565
+ settingsPath,
566
+ expected: expected.length,
567
+ settingsMissing: expected.length > 0,
568
+ unwired: [],
569
+ };
570
+ }
571
+ let config;
572
+ try {
573
+ config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
574
+ }
575
+ catch {
576
+ return {
577
+ supported: true,
578
+ settingsPath,
579
+ expected: expected.length,
580
+ settingsUnparseable: true,
581
+ unwired: [],
582
+ };
583
+ }
584
+ // Command strings actually referenced, keyed by (event, matcher) — a hook wired
585
+ // under the WRONG matcher group must NOT read as wired, so scope by matcher and
586
+ // not just by event.
587
+ const wiredByGroup = new Map();
588
+ const groupKey = (event, matcher) => `${event}\n${matcher}`;
589
+ const hooks = config.hooks && typeof config.hooks === 'object'
590
+ ? config.hooks
591
+ : {};
592
+ for (const [event, groups] of Object.entries(hooks)) {
593
+ if (!Array.isArray(groups))
594
+ continue;
595
+ for (const group of groups) {
596
+ if (!group || !Array.isArray(group.hooks))
597
+ continue;
598
+ const matcher = typeof group.matcher === 'string' ? group.matcher : '';
599
+ const key = groupKey(event, matcher);
600
+ let cmds = wiredByGroup.get(key);
601
+ if (!cmds) {
602
+ cmds = new Set();
603
+ wiredByGroup.set(key, cmds);
604
+ }
605
+ for (const h of group.hooks) {
606
+ if (h && typeof h.command === 'string')
607
+ cmds.add(h.command);
608
+ }
609
+ }
610
+ }
611
+ const unwired = expected.filter((e) => !wiredByGroup.get(groupKey(e.event, e.matcher))?.has(e.command));
612
+ return { supported: true, settingsPath, expected: expected.length, unwired };
613
+ }
491
614
  /**
492
615
  * Check if a hook installed in a specific version matches central content.
493
616
  */
@@ -936,6 +1059,9 @@ function sweepOrphanShims(manifest) {
936
1059
  }
937
1060
  }
938
1061
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
1062
+ if (isAgentHardDeprecated(agentId)) {
1063
+ return { registered: [], errors: [] };
1064
+ }
939
1065
  const manifest = hookManifest || parseHookManifest();
940
1066
  if (Object.keys(manifest).length === 0) {
941
1067
  if (agentId === 'opencode') {
@@ -995,9 +1121,6 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
995
1121
  if (agentId === 'codex') {
996
1122
  return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
997
1123
  }
998
- if (agentId === 'gemini') {
999
- return registerHooksForGemini(versionHome, manifest, resolveScript, managedPrefixes);
1000
- }
1001
1124
  if (agentId === 'antigravity') {
1002
1125
  return registerHooksForAntigravity(versionHome, manifest, resolveScript, managedPrefixes);
1003
1126
  }
@@ -1183,16 +1306,6 @@ const ANTIGRAVITY_EVENT_MAP = {
1183
1306
  Stop: 'on_loop_stop',
1184
1307
  OnError: 'on_error',
1185
1308
  };
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
1309
  function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
1197
1310
  const registered = [];
1198
1311
  const errors = [];
@@ -1387,12 +1500,17 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1387
1500
  if (resolved)
1388
1501
  currentManifestPaths.add(resolved);
1389
1502
  }
1503
+ // Identity of the version home being synced. Codex stores hooks in a shared
1504
+ // hooks.json per CODEX_HOME, but agents-cli registers version-scoped command
1505
+ // paths; without this, old version entries keep firing after upgrades.
1506
+ const currentVh = versionHomeIdentity(versionHome);
1390
1507
  // Remove stale entries from all event groups
1391
1508
  for (const eventGroups of Object.values(hooksFile.hooks)) {
1392
1509
  for (const group of eventGroups) {
1393
1510
  if (!group.hooks)
1394
1511
  continue;
1395
- group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
1512
+ group.hooks = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
1513
+ !isStaleSiblingVersionCommand(h.command, currentVh));
1396
1514
  }
1397
1515
  }
1398
1516
  for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
@@ -1436,7 +1554,8 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1436
1554
  group.hooks = [];
1437
1555
  }
1438
1556
  const existingIdx = group.hooks.findIndex((h) => h.command === commandPath);
1439
- const hookEntry = { type: 'command', command: commandPath, timeout };
1557
+ const eventTimeout = event === 'SessionEnd' ? Math.min(timeout, 3) : timeout;
1558
+ const hookEntry = { type: 'command', command: commandPath, timeout: eventTimeout };
1440
1559
  if (existingIdx >= 0) {
1441
1560
  group.hooks[existingIdx] = hookEntry;
1442
1561
  }
@@ -1529,81 +1648,6 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1529
1648
  }
1530
1649
  return { registered, errors };
1531
1650
  }
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
1651
  /**
1608
1652
  * Register hooks into antigravity's (agy) settings.json. Unlike gemini, agy uses
1609
1653
  * 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;