@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.
- package/CHANGELOG.md +108 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +2 -1
- package/dist/commands/doctor.js +25 -6
- package/dist/commands/menubar.js +12 -0
- package/dist/commands/routines.d.ts +8 -1
- package/dist/commands/routines.js +229 -75
- package/dist/commands/sessions.d.ts +84 -0
- package/dist/commands/sessions.js +48 -19
- package/dist/index.js +47 -0
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.js +34 -3
- package/dist/lib/hooks.d.ts +29 -0
- package/dist/lib/hooks.js +109 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +24 -0
- package/dist/lib/menubar/install-menubar.js +52 -2
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +116 -0
- package/dist/lib/scheduler.js +16 -4
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +32 -4
- package/dist/lib/state.js +57 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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 (
|
|
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);
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
815
|
+
for (const dir of addDirs) {
|
|
785
816
|
cmd.push('--add-dir', dir);
|
|
786
817
|
}
|
|
787
818
|
}
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
Binary file
|
|
@@ -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
|
|
449
|
-
|
|
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
|
}
|