@phnx-labs/agents-cli 1.20.82 → 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.
@@ -3,6 +3,66 @@ import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
5
5
  import { type PickedSession } from './sessions-picker.js';
6
+ interface SessionFilterOptions {
7
+ agent?: string;
8
+ project?: string;
9
+ all?: boolean;
10
+ teams?: boolean;
11
+ routine?: boolean;
12
+ since?: string;
13
+ until?: string;
14
+ }
15
+ interface SessionsOptions extends SessionFilterOptions {
16
+ /** Also list sessions from the user's own unmanaged ~/.<agent> installs. */
17
+ unmanaged?: boolean;
18
+ query?: string;
19
+ limit?: string;
20
+ sort?: string;
21
+ json?: boolean;
22
+ markdown?: boolean;
23
+ /** Commander populates this from `--no-redact`: true by default, false when the flag is passed. */
24
+ redact?: boolean;
25
+ include?: string;
26
+ exclude?: string;
27
+ first?: string;
28
+ last?: string;
29
+ artifacts?: boolean;
30
+ artifact?: string;
31
+ active?: boolean;
32
+ /** Emit the on-disk session-scan directories (requires --json); for watchers. */
33
+ roots?: boolean;
34
+ cloud?: boolean;
35
+ host?: string[];
36
+ /** Group the listing by directory and drop the id/version columns. */
37
+ tree?: boolean;
38
+ /** Force the plain flat table instead of the grouped default overview. */
39
+ flat?: boolean;
40
+ /** With --active: show only sessions waiting on user input; exit 1 if any. */
41
+ waiting?: boolean;
42
+ /** Enrich the listing with live glyphs/preview for running rows. Default on;
43
+ * `--no-live` sets this false. Commander's `--no-` convention. */
44
+ live?: boolean;
45
+ /** Force local-only: skip the cross-machine SSH fan-out (both the default
46
+ * listing and --active). */
47
+ local?: boolean;
48
+ /** --device <target...> — alias for --host; resolves against the device registry. */
49
+ device?: string[];
50
+ /** Per-agent shorthands: aliases for `--agent <name>` (prioritized harnesses). */
51
+ claude?: boolean;
52
+ codex?: boolean;
53
+ kimi?: boolean;
54
+ antigravity?: boolean;
55
+ grok?: boolean;
56
+ opencode?: boolean;
57
+ /** Force the printed listing even on a TTY. Commander's `--no-` convention:
58
+ * `--no-interactive` sets this false, opting out of the interactive browser. */
59
+ interactive?: boolean;
60
+ /** Print the canonical `ag sessions …` command for the given flags and exit —
61
+ * the non-interactive twin of the browser's `y` hotkey. */
62
+ printCmd?: boolean;
63
+ /** Print a compact preview of the matched session and exit (no pager). */
64
+ preview?: boolean;
65
+ }
6
66
  /**
7
67
  * Strip terminal/harness noise from a preview so the column stays a single line
8
68
  * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
@@ -10,6 +70,16 @@ import { type PickedSession } from './sessions-picker.js';
10
70
  * a captured transcript tail. Collapses runs of whitespace.
11
71
  */
12
72
  export declare function cleanPreview(text: string): string;
73
+ /**
74
+ * Build the live description for an active session: checklist progress (when
75
+ * present) plus the state engine's preview (the latest turn), a user label, or
76
+ * the first-prompt topic. Used by both the flat listing's `doing` cell and as
77
+ * the snippet half of the --active row (identity is layered on in printActiveRow).
78
+ *
79
+ * Covers every ActiveSession context: terminal (interactive), headless, teams,
80
+ * cloud, and sub-agent rows that share the same ActiveSession.todos field.
81
+ */
82
+ export declare function buildSessionDescription(s: ActiveSession): string;
13
83
  /**
14
84
  * Identity + checklist + live snippet for an --active / cross-machine row.
15
85
  * Surfaces agent-adjacent identity the flat table already has (label, project)
@@ -190,6 +260,20 @@ export declare function gatherActiveSessions(opts?: {
190
260
  sessions: ActiveSession[];
191
261
  remoteDeviceCount: number;
192
262
  }>;
263
+ /**
264
+ * A bare interactive fleet listing — no query, no render/filter flag — that the
265
+ * `runSessionBrowser` picker can represent. The single predicate shared by the
266
+ * bare-browser branch and the `--host` early-return guard so they can't drift:
267
+ * when this holds, an explicit `--host`/`--device` scope is folded into the
268
+ * browser (preview-rich, selectable) instead of the legacy per-host raw stream.
269
+ */
270
+ export declare function isBareBrowserListing(options: SessionsOptions, query: string | undefined): boolean;
271
+ /**
272
+ * Pure flag-gate half of {@link isBareBrowserListing} (TTY-independent, so it is
273
+ * unit-testable): true when no query, render, or filter flag is present that the
274
+ * `runSessionBrowser` picker cannot represent.
275
+ */
276
+ export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
193
277
  /** One flat table row:
194
278
  * shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
195
279
  * `doing` is the live preview when running, else the topic. The `ticket` column
@@ -240,7 +240,7 @@ export function cleanPreview(text) {
240
240
  * Covers every ActiveSession context: terminal (interactive), headless, teams,
241
241
  * cloud, and sub-agent rows that share the same ActiveSession.todos field.
242
242
  */
243
- function buildSessionDescription(s) {
243
+ export function buildSessionDescription(s) {
244
244
  const todo = formatTodoCompact(s.todos);
245
245
  if (s.context === 'cloud') {
246
246
  const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
@@ -248,6 +248,11 @@ function buildSessionDescription(s) {
248
248
  }
249
249
  if (s.context === 'teams') {
250
250
  const parts = [s.teamName];
251
+ // Lineage: which orchestrator spun up this team. Prefer the resolved label,
252
+ // else the short session id, so "by whom" is answerable at a glance.
253
+ const orch = s.orchestratorLabel || (s.orchestratorSessionId ? s.orchestratorSessionId.slice(0, 8) : '');
254
+ if (orch)
255
+ parts.push(`by ${orch}`);
251
256
  if (todo)
252
257
  parts.push(todo);
253
258
  if (s.preview)
@@ -997,6 +1002,33 @@ function printCrossMachineTip() {
997
1002
  function useInteractiveBrowser(options) {
998
1003
  return options.interactive !== false && !options.json && isInteractiveTerminal();
999
1004
  }
1005
+ /**
1006
+ * A bare interactive fleet listing — no query, no render/filter flag — that the
1007
+ * `runSessionBrowser` picker can represent. The single predicate shared by the
1008
+ * bare-browser branch and the `--host` early-return guard so they can't drift:
1009
+ * when this holds, an explicit `--host`/`--device` scope is folded into the
1010
+ * browser (preview-rich, selectable) instead of the legacy per-host raw stream.
1011
+ */
1012
+ export function isBareBrowserListing(options, query) {
1013
+ return useInteractiveBrowser(options) && hasNoBrowserDisqualifyingFlags(options, query);
1014
+ }
1015
+ /**
1016
+ * Pure flag-gate half of {@link isBareBrowserListing} (TTY-independent, so it is
1017
+ * unit-testable): true when no query, render, or filter flag is present that the
1018
+ * `runSessionBrowser` picker cannot represent.
1019
+ */
1020
+ export function hasNoBrowserDisqualifyingFlags(options, query) {
1021
+ return (!query &&
1022
+ !options.routine &&
1023
+ !options.flat &&
1024
+ !options.tree &&
1025
+ !options.markdown &&
1026
+ !options.until &&
1027
+ !options.project &&
1028
+ !options.sort &&
1029
+ !options.artifacts &&
1030
+ options.artifact === undefined);
1031
+ }
1000
1032
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1001
1033
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
1002
1034
  function canonicalSessionsCommand(query, options) {
@@ -1099,14 +1131,21 @@ async function sessionsAction(query, options) {
1099
1131
  await runRemoteSessionsJson(options.host);
1100
1132
  return;
1101
1133
  }
1102
- try {
1103
- runRemoteSessions(options.host);
1104
- }
1105
- catch (err) {
1106
- console.error(chalk.red(err.message));
1107
- process.exit(1);
1134
+ // A bare interactive `--host`/`--device <box>` listing falls through to the
1135
+ // fleet browser below, which folds the named host(s) into the same merged,
1136
+ // preview-rich, selectable view as the local listing (via gatherRemoteList).
1137
+ // A query, a render/filter flag, or a non-interactive caller keeps the legacy
1138
+ // per-host raw stream under a `── host ──` banner.
1139
+ if (!isBareBrowserListing(options, query)) {
1140
+ try {
1141
+ runRemoteSessions(options.host);
1142
+ }
1143
+ catch (err) {
1144
+ console.error(chalk.red(err.message));
1145
+ process.exit(1);
1146
+ }
1147
+ return;
1108
1148
  }
1109
- return;
1110
1149
  }
1111
1150
  // --preview <id/query>: resolve one session and print its compact preview, then
1112
1151
  // exit — checked before --active so `--active --preview <id>` peeks the id
@@ -1160,17 +1199,7 @@ async function sessionsAction(query, options) {
1160
1199
  // filter the browser can't represent), or --no-interactive keep the existing
1161
1200
  // printed/render paths (agents and scripts unaffected). An explicit --since seeds
1162
1201
  // the browser's window so the flag is honored, not swallowed.
1163
- if (useInteractiveBrowser(options) &&
1164
- !query &&
1165
- !options.routine &&
1166
- !options.flat &&
1167
- !options.tree &&
1168
- !options.markdown &&
1169
- !options.until &&
1170
- !options.project &&
1171
- !options.sort &&
1172
- !options.artifacts &&
1173
- options.artifact === undefined) {
1202
+ if (isBareBrowserListing(options, query)) {
1174
1203
  const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
1175
1204
  await runSessionBrowser(bareBrowserSeed({
1176
1205
  teams: options.teams,
package/dist/index.js CHANGED
@@ -656,6 +656,41 @@ function registerJobsCronAliasCommand(p, alias) {
656
656
  await program.parseAsync(['node', 'agents', ...args]);
657
657
  });
658
658
  }
659
+ /**
660
+ * Removed `check` command (RUSH-1234) — re-parses as `doctor --check`, forwarding
661
+ * any remaining flags so `check --quiet` / `check --json` / `check --devices` keep
662
+ * working and the drift-gate exit code survives the rename. The notice goes to
663
+ * stderr so `--json` stdout stays clean for CI parsers.
664
+ */
665
+ function registerCheckTombstoneCommand(p) {
666
+ p.command('check', { hidden: true })
667
+ .allowUnknownOption()
668
+ .allowExcessArguments()
669
+ .action(async () => {
670
+ console.error(chalk.yellow('Deprecated: "agents check" is now "agents doctor --check". Running that for you.\n'));
671
+ const args = process.argv.slice(2);
672
+ args[0] = 'doctor';
673
+ args.splice(1, 0, '--check');
674
+ await program.parseAsync(['node', 'agents', ...args]);
675
+ });
676
+ }
677
+ /**
678
+ * Removed `resources` command (RUSH-1234) — re-parses as `view --merged` (the
679
+ * cross-layer, first-wins resource table now lives there; `agents inspect <target>`
680
+ * covers per-agent / per-repo detail). Forwards remaining flags like `--json`.
681
+ */
682
+ function registerResourcesTombstoneCommand(p) {
683
+ p.command('resources', { hidden: true })
684
+ .allowUnknownOption()
685
+ .allowExcessArguments()
686
+ .action(async () => {
687
+ console.error(chalk.yellow('Deprecated: "agents resources" is now "agents view --merged" (use "agents inspect <target>" for per-agent/per-repo detail). Running that for you.\n'));
688
+ const args = process.argv.slice(2);
689
+ args[0] = 'view';
690
+ args.splice(1, 0, '--merged');
691
+ await program.parseAsync(['node', 'agents', ...args]);
692
+ });
693
+ }
659
694
  /** Self-upgrade command (`agents upgrade [version]`). */
660
695
  function registerUpgradeCommand(p) {
661
696
  p.command('upgrade')
@@ -739,6 +774,16 @@ async function registerEagerForRequest(name) {
739
774
  registerJobsCronAliasCommand(program, name);
740
775
  await reg(loadRoutines);
741
776
  return true;
777
+ case 'check':
778
+ // The action re-parses as `doctor --check`, so doctor must exist too.
779
+ registerCheckTombstoneCommand(program);
780
+ await reg(loadDoctor);
781
+ return true;
782
+ case 'resources':
783
+ // The action re-parses as `view --merged`, so view must exist too.
784
+ registerResourcesTombstoneCommand(program);
785
+ await reg(loadView);
786
+ return true;
742
787
  case 'upgrade':
743
788
  registerUpgradeCommand(program);
744
789
  return true;
@@ -759,6 +804,7 @@ async function registerEagerForRequest(name) {
759
804
  */
760
805
  async function registerAllEagerCommands() {
761
806
  await reg(loadView);
807
+ registerResourcesTombstoneCommand(program);
762
808
  await reg(loadShare);
763
809
  await reg(loadSend);
764
810
  await reg(loadInspect);
@@ -791,6 +837,7 @@ async function registerAllEagerCommands() {
791
837
  await reg(loadTrash);
792
838
  await reg(loadRestore);
793
839
  await reg(loadDoctor);
840
+ registerCheckTombstoneCommand(program);
794
841
  await reg(loadApply);
795
842
  await reg(loadStatus);
796
843
  registerExecAliasCommand(program);
@@ -262,6 +262,13 @@ export declare const AGENT_COMMANDS: Record<AgentId, AgentCommandTemplate>;
262
262
  * return false resume via the universal Tier-2 `/continue` replay instead.
263
263
  */
264
264
  export declare function nativeResume(agent: AgentId): boolean;
265
+ /**
266
+ * Build the `-c` value that adds `dir` to codex's workspace-write writable
267
+ * roots. Codex parses the value as TOML, so it's a single-element TOML array of
268
+ * one quoted string. Used on codex resume forms, which reject `--add-dir` and
269
+ * only accept `-c` config overrides.
270
+ */
271
+ export declare function codexWritableRootsConfig(dir: string): string;
265
272
  /** Assemble the full CLI argument array for an agent invocation. */
266
273
  export declare function buildExecCommand(options: ExecOptions): string[];
267
274
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
package/dist/lib/exec.js CHANGED
@@ -16,7 +16,7 @@ import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { resolveActor, actorEnv } from './actor.js';
19
- import { getShimsDir, getHistoryDir } from './state.js';
19
+ import { getShimsDir, getHistoryDir, getUserAgentsDir } from './state.js';
20
20
  import { resolveCodexHome } from './codex-home.js';
21
21
  import { readCodexConfiguredModel } from './shims.js';
22
22
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
@@ -573,6 +573,15 @@ export const AGENT_COMMANDS = {
573
573
  export function nativeResume(agent) {
574
574
  return AGENT_COMMANDS[agent]?.resume !== undefined;
575
575
  }
576
+ /**
577
+ * Build the `-c` value that adds `dir` to codex's workspace-write writable
578
+ * roots. Codex parses the value as TOML, so it's a single-element TOML array of
579
+ * one quoted string. Used on codex resume forms, which reject `--add-dir` and
580
+ * only accept `-c` config overrides.
581
+ */
582
+ export function codexWritableRootsConfig(dir) {
583
+ return `sandbox_workspace_write.writable_roots=[${JSON.stringify(dir)}]`;
584
+ }
576
585
  /** Assemble the full CLI argument array for an agent invocation. */
577
586
  export function buildExecCommand(options) {
578
587
  const template = AGENT_COMMANDS[options.agent];
@@ -658,6 +667,18 @@ export function buildExecCommand(options) {
658
667
  // AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
659
668
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
660
669
  }
670
+ // Codex's workspace-write sandbox blocks $HOME (verified against the live CLI
671
+ // and OpenAI's sandbox docs: writable roots extend scope "without removing the
672
+ // sandbox entirely"). But the model routinely shells out to `agents ...`, whose
673
+ // runtime state lives under ~/.agents — the SSH askpass shim
674
+ // (~/.agents/.cache/devices/askpass.sh), the device/stats cache, secrets,
675
+ // session writes, config tunings. Without ~/.agents as a writable root those
676
+ // inner writes fail with EROFS (e.g. `agents ssh` dies before connecting),
677
+ // which is why a remote `agents run codex` couldn't reach the fleet. Grant it
678
+ // implicitly whenever codex runs workspace-write — far narrower than --mode skip
679
+ // (danger-full-access). Fresh runs take --add-dir (below); resume forms reject
680
+ // --add-dir, so they take the same root via -c writable_roots here.
681
+ const codexWorkspaceWrite = options.agent === 'codex' && resolvedMode === 'edit';
661
682
  if (resumeSpec && 'subcommand' in resumeSpec) {
662
683
  if (resolvedMode === 'skip') {
663
684
  // skip = yolo on resume too; both `codex resume` (TUI) and
@@ -667,6 +688,8 @@ export function buildExecCommand(options) {
667
688
  else if (interactive) {
668
689
  // `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
669
690
  cmd.push(...modeFlags);
691
+ if (codexWorkspaceWrite)
692
+ cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
670
693
  }
671
694
  else {
672
695
  // `codex exec resume` rejects `--sandbox <mode>` (verified against
@@ -676,6 +699,7 @@ export function buildExecCommand(options) {
676
699
  cmd.push('-c', `sandbox_mode=${resolvedMode === 'plan' ? 'read-only' : 'workspace-write'}`);
677
700
  if (resolvedMode !== 'plan') {
678
701
  cmd.push('-c', 'sandbox_workspace_write.network_access=true');
702
+ cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
679
703
  }
680
704
  }
681
705
  }
@@ -779,9 +803,16 @@ export function buildExecCommand(options) {
779
803
  // claude-only, silently dropped for codex and masked by edit mode carrying
780
804
  // the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
781
805
  // skip it there (claude's flag-based resume accepts it).
782
- if (options.addDirs &&
806
+ //
807
+ // On top of any user-supplied dirs, a fresh codex workspace-write run
808
+ // implicitly gets ~/.agents (deduped) so the CLI's own tooling — askpass,
809
+ // secrets, sessions, tunings — can write from inside the sandbox. Resume forms
810
+ // get the same root via -c writable_roots above (see codexWorkspaceWrite).
811
+ const codexImplicitDirs = codexWorkspaceWrite && !options.resume ? [getUserAgentsDir()] : [];
812
+ const addDirs = [...new Set([...(options.addDirs ?? []), ...codexImplicitDirs])];
813
+ if (addDirs.length > 0 &&
783
814
  (options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
784
- for (const dir of options.addDirs) {
815
+ for (const dir of addDirs) {
785
816
  cmd.push('--add-dir', dir);
786
817
  }
787
818
  }
@@ -21,12 +21,41 @@ export declare function resolveHookScriptPath(script: string): string | null;
21
21
  * os.homedir()/path.sep at the call site.
22
22
  */
23
23
  export declare function toPortableCommand(absPath: string, home?: string, sep?: string): string;
24
+ /**
25
+ * Collapse version-scoped registrations by logical resource identity rather
26
+ * than absolute command path. When the same resource is present in several
27
+ * homes, the active home's command wins even if it appears later. Commands
28
+ * outside version homes are user-owned and remain untouched.
29
+ */
30
+ export declare function deduplicateVersionHookCommands(commands: string[], activeVersionHome: string): string[];
24
31
  import type { AgentId, InstalledHook, ManifestHook } from './types.js';
25
32
  export type HookEntry = {
26
33
  name: string;
27
34
  scriptPath: string;
28
35
  dataFile?: string;
29
36
  };
37
+ export interface VersionHookCopy {
38
+ agent: AgentId;
39
+ version: string;
40
+ name: string;
41
+ path: string;
42
+ hash: string;
43
+ active: boolean;
44
+ }
45
+ export interface DuplicateVersionHook {
46
+ agent: AgentId;
47
+ name: string;
48
+ kind: 'duplicate' | 'drift';
49
+ authoritative: VersionHookCopy;
50
+ copies: VersionHookCopy[];
51
+ }
52
+ /**
53
+ * Inspect every installed hooks-capable harness without assuming a native
54
+ * settings format. Same-name copies with one content hash are installation
55
+ * noise; multiple hashes are drift. The active version is always selected as
56
+ * the authoritative copy when it participates in the group.
57
+ */
58
+ export declare function inspectDuplicateVersionHooks(cwd?: string): DuplicateVersionHook[];
30
59
  /**
31
60
  * List hook entries in a single directory, grouping script + data files by
32
61
  * basename. Exported so doctor-diff can reuse the same grouping the sync path
package/dist/lib/hooks.js CHANGED
@@ -146,9 +146,113 @@ function isStaleSiblingVersionCommand(command, current) {
146
146
  const id = versionHomeIdentity(command);
147
147
  return id !== null && id.agent === current.agent && id.version !== current.version;
148
148
  }
149
- import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.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';
150
203
  import { generateHookShim, getHookShimPath, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
151
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
+ }
152
256
  /**
153
257
  * Resolve the command path to register for a hook.
154
258
  *
@@ -1351,8 +1455,9 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1351
1455
  for (const group of eventEntries) {
1352
1456
  if (!group.hooks)
1353
1457
  continue;
1354
- 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)) &&
1355
1459
  !isStaleSiblingVersionCommand(h.command, currentVh));
1460
+ group.hooks = collapseVersionHookEntries(managed, versionHome);
1356
1461
  }
1357
1462
  }
1358
1463
  // Remove empty matcher groups left after cleanup
@@ -1509,8 +1614,9 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
1509
1614
  for (const group of eventGroups) {
1510
1615
  if (!group.hooks)
1511
1616
  continue;
1512
- 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)) &&
1513
1618
  !isStaleSiblingVersionCommand(h.command, currentVh));
1619
+ group.hooks = collapseVersionHookEntries(managed, versionHome);
1514
1620
  }
1515
1621
  }
1516
1622
  for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
@@ -124,6 +124,28 @@ export declare function disableMenubarService(): void;
124
124
  * Best-effort — never throws into startup.
125
125
  */
126
126
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
127
+ /** A live MenubarHelper process: its pid and the executable it is running. */
128
+ export interface MenubarProcess {
129
+ pid: number;
130
+ executable: string;
131
+ }
132
+ /**
133
+ * Split the live MenubarHelper processes into the installed bundle's own
134
+ * (`running`) and every other copy (`foreign`).
135
+ *
136
+ * `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
137
+ * the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
138
+ * still reported a healthy `running: yes` — the paste was dead and nothing said
139
+ * so. A foreign copy is the thing to look for, so name it.
140
+ *
141
+ * Identity comes from `comm` (the resolved executable), never from a substring
142
+ * of the command line: matching the latter flags any shell that merely mentions
143
+ * MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
144
+ */
145
+ export declare function classifyMenubarProcesses(commOutput: string, commandOutput: string, installedExec: string): {
146
+ running: boolean;
147
+ foreign: MenubarProcess[];
148
+ };
127
149
  export interface MenubarStatus {
128
150
  platform: string;
129
151
  source: string | null;
@@ -133,6 +155,8 @@ export interface MenubarStatus {
133
155
  stale: boolean;
134
156
  serviceInstalled: boolean;
135
157
  running: boolean;
158
+ /** Live MenubarHelper processes that are NOT the installed bundle. */
159
+ foreignInstances: MenubarProcess[];
136
160
  disabledByUser: boolean;
137
161
  }
138
162
  export declare function getMenubarStatus(): MenubarStatus;
@@ -441,12 +441,61 @@ export function installMenubarLaunchAgentOnUpgrade() {
441
441
  /* never block startup on the menu bar */
442
442
  }
443
443
  }
444
+ /** Parse `ps -axo pid=,<field>=` into pid -> field. The field is the rest of the
445
+ * line, so a path containing spaces (App Support does) survives intact. */
446
+ function parsePsLines(psOutput) {
447
+ const out = new Map();
448
+ for (const line of psOutput.split('\n')) {
449
+ const m = /^\s*(\d+)\s+(.+)$/.exec(line);
450
+ if (m)
451
+ out.set(Number(m[1]), m[2]);
452
+ }
453
+ return out;
454
+ }
455
+ /**
456
+ * Split the live MenubarHelper processes into the installed bundle's own
457
+ * (`running`) and every other copy (`foreign`).
458
+ *
459
+ * `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
460
+ * the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
461
+ * still reported a healthy `running: yes` — the paste was dead and nothing said
462
+ * so. A foreign copy is the thing to look for, so name it.
463
+ *
464
+ * Identity comes from `comm` (the resolved executable), never from a substring
465
+ * of the command line: matching the latter flags any shell that merely mentions
466
+ * MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
467
+ */
468
+ export function classifyMenubarProcesses(commOutput, commandOutput, installedExec) {
469
+ const commands = parsePsLines(commandOutput);
470
+ const foreign = [];
471
+ let running = false;
472
+ for (const [pid, executable] of parsePsLines(commOutput)) {
473
+ if (path.basename(executable) !== 'MenubarHelper')
474
+ continue;
475
+ // `--notify` is a one-shot that posts a notification and exits; it runs the
476
+ // installed binary but never claims the status item or the chords.
477
+ if ((commands.get(pid) || '').includes('--notify'))
478
+ continue;
479
+ if (executable === installedExec)
480
+ running = true;
481
+ else
482
+ foreign.push({ pid, executable });
483
+ }
484
+ return { running, foreign };
485
+ }
444
486
  export function getMenubarStatus() {
445
487
  const dest = installedAppPath();
446
488
  let running = false;
489
+ let foreignInstances = [];
447
490
  if (onDarwin()) {
448
- const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
449
- running = r.status === 0 && (r.stdout || '').trim().length > 0;
491
+ const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
492
+ const comm = ps('pid=,comm=');
493
+ const command = ps('pid=,command=');
494
+ if (comm.status === 0 && command.status === 0) {
495
+ const c = classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
496
+ running = c.running;
497
+ foreignInstances = c.foreign;
498
+ }
450
499
  }
451
500
  const serviceInstalled = menubarServiceInstalled();
452
501
  return {
@@ -458,6 +507,7 @@ export function getMenubarStatus() {
458
507
  stale: onDarwin() && serviceInstalled && menubarSetupStale(),
459
508
  serviceInstalled,
460
509
  running,
510
+ foreignInstances,
461
511
  disabledByUser: menubarDisabledByUser(),
462
512
  };
463
513
  }