@phnx-labs/agents-cli 1.22.107 → 1.22.109

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 (38) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/commands/daemon.js +45 -40
  3. package/dist/commands/doctor.js +7 -0
  4. package/dist/commands/exec.js +4 -2
  5. package/dist/commands/monitors.js +15 -2
  6. package/dist/commands/routines.js +46 -6
  7. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  8. package/dist/commands/sessions-backup-setup.js +65 -0
  9. package/dist/commands/sessions-resume.d.ts +14 -1
  10. package/dist/commands/sessions-resume.js +56 -9
  11. package/dist/commands/sessions.js +2 -0
  12. package/dist/commands/share.js +15 -2
  13. package/dist/commands/sync.js +30 -1
  14. package/dist/lib/accounts/slots.js +7 -0
  15. package/dist/lib/agent-spec/agents.d.ts +60 -8
  16. package/dist/lib/agent-spec/agents.js +118 -45
  17. package/dist/lib/daemon/daemon.d.ts +34 -1
  18. package/dist/lib/daemon/daemon.js +63 -9
  19. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  20. package/dist/lib/daemon/leaked-daemons.js +180 -0
  21. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  22. package/dist/lib/devices/doctor-findings.js +32 -1
  23. package/dist/lib/hooks/install.js +70 -63
  24. package/dist/lib/models.js +76 -5
  25. package/dist/lib/session/cloud.js +3 -1
  26. package/dist/lib/session/parse.d.ts +1 -0
  27. package/dist/lib/session/parse.js +136 -2
  28. package/dist/lib/session/recovery.d.ts +9 -1
  29. package/dist/lib/session/recovery.js +14 -4
  30. package/dist/lib/session/tool-calls.d.ts +1 -1
  31. package/dist/lib/session/tool-calls.js +40 -5
  32. package/dist/lib/share/backend.d.ts +23 -0
  33. package/dist/lib/share/backend.js +24 -0
  34. package/dist/lib/share/provision.d.ts +12 -0
  35. package/dist/lib/share/provision.js +30 -0
  36. package/dist/lib/share/worker-template.js +273 -0
  37. package/dist/lib/terminal/engine.js +13 -1
  38. package/package.json +1 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The daemon state dir for an ARBITRARY home — the same layout state.ts's
3
+ * DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
4
+ * under a redirected HOME (a test/e2e harness) uses this to find the REAL
5
+ * install's daemon records. Kept here rather than exported from state.ts: any
6
+ * edit to state.ts selects its entire static-import closure into the required
7
+ * impact gate (186 files, 295s against the 240s budget, measured on this
8
+ * change's first CI run), so the helper cannot live there without a
9
+ * budget-policy change. If state.ts's layout ever moves, this must move with
10
+ * it — the two are the same address.
11
+ */
12
+ export declare function getDaemonDirForHome(home: string): string;
13
+ /** One live `__daemon-run` process from the box-wide `ps` scan. */
14
+ export interface DaemonRunProcess {
15
+ pid: number;
16
+ /** Owning uid from `ps`, or null when unavailable. */
17
+ uid: number | null;
18
+ /** Whitespace-tokenized argv (`ps` renders it unquoted). */
19
+ tokens: string[];
20
+ }
21
+ /**
22
+ * Every live `__daemon-run` process on this box, regardless of which install
23
+ * launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
24
+ * Windows.
25
+ *
26
+ * `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
27
+ * after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
28
+ * A substring/regex test anywhere in the full command line is not enough: an
29
+ * `agents run claude "<prompt>"` invocation whose prompt happens to quote the
30
+ * literal text `__daemon-run` (this ticket's own brief does) matches that test
31
+ * too, and was observed producing false "duplicate daemon" rows. Requiring it
32
+ * to be the LAST whitespace-delimited token is the actual invariant.
33
+ *
34
+ * This raw box-wide scan is deliberately NOT the duplicate-detection scope
35
+ * (RUSH-2368): a `__daemon-run` under a different HOME serves a different
36
+ * `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
37
+ * `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
38
+ * this scan and was reported as a stray to `kill`. Callers attach their own
39
+ * scope: `agents daemon status` gates on the instance registry,
40
+ * {@link findLeakedDaemons} on the owner records above.
41
+ */
42
+ export declare function listDaemonRunProcesses(): DaemonRunProcess[];
43
+ /** A `__daemon-run` process no owner record names. */
44
+ export interface LeakedDaemon {
45
+ pid: number;
46
+ /** The HOME the process runs under, or null when it cannot be read. */
47
+ home: string | null;
48
+ /** The process's start time as `ps lstart` renders it, or null when unavailable. */
49
+ startedAt: string | null;
50
+ /** The launch entry (the argv token before `__daemon-run`), best-effort. */
51
+ entry: string | null;
52
+ }
53
+ /**
54
+ * Every `__daemon-run` process running as THIS uid that no owner record names:
55
+ * neither the service manager's unit main PID nor the recorded
56
+ * `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
57
+ * time so the report can show them. An empty list means no leak — including
58
+ * the common stopped-daemon case, where there is simply nothing running.
59
+ */
60
+ export declare function findLeakedDaemons(): LeakedDaemon[];
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Leaked-daemon detection (W4, PHNX-3736).
3
+ *
4
+ * One daemon per device is the contract: the always-on process behind
5
+ * `agents __daemon-run` is either the service manager's unit main PID or the
6
+ * pid recorded in `<daemonDir>/daemon.pid`. Anything else running
7
+ * `__daemon-run` as this uid is a LEAK — a daemon nothing owns.
8
+ *
9
+ * The motivating incident ran 4+ days on yosemite-s1: a headless e2e session
10
+ * launched a daemon under `HOME=/tmp/pin-e2e-<pid>` and never stopped it. The
11
+ * pid-file takeover in `daemon.ts` could not see it because it keeps its own
12
+ * pid file under the temp home, and `agents daemon status`'s duplicate scope
13
+ * (`findSurvivingStateDirDaemons`, RUSH-2368) deliberately covers only this
14
+ * install's state dir — so the leak was invisible to every existing surface.
15
+ *
16
+ * Why flagging a different-HOME daemon here is not the RUSH-2368 harm: that
17
+ * incident taught that a `__daemon-run` under another HOME is not a DUPLICATE
18
+ * of this device's daemon and must never be told to `kill` from the duplicate
19
+ * path. This module does not accuse it of being a duplicate — it reports that
20
+ * NO owner record (unit main PID, recorded daemon.pid) names the pid at all,
21
+ * and shows the process's own HOME and start time so the operator can judge
22
+ * before acting. A different uid is never named: we cannot inspect its
23
+ * environment or signal it.
24
+ */
25
+ import { execFileSync } from 'child_process';
26
+ import * as fs from 'fs';
27
+ import * as os from 'os';
28
+ import * as path from 'path';
29
+ import { productionDaemonServiceNames, readDaemonPid, readServiceManagerPid } from './daemon.js';
30
+ import { getDaemonDir } from '../state.js';
31
+ /**
32
+ * The daemon state dir for an ARBITRARY home — the same layout state.ts's
33
+ * DAEMON_DIR chain produces (`<home>/.agents/.cache/helpers/daemon`). A caller
34
+ * under a redirected HOME (a test/e2e harness) uses this to find the REAL
35
+ * install's daemon records. Kept here rather than exported from state.ts: any
36
+ * edit to state.ts selects its entire static-import closure into the required
37
+ * impact gate (186 files, 295s against the 240s budget, measured on this
38
+ * change's first CI run), so the helper cannot live there without a
39
+ * budget-policy change. If state.ts's layout ever moves, this must move with
40
+ * it — the two are the same address.
41
+ */
42
+ export function getDaemonDirForHome(home) {
43
+ return path.join(home, '.agents', '.cache', 'helpers', 'daemon');
44
+ }
45
+ /**
46
+ * Every live `__daemon-run` process on this box, regardless of which install
47
+ * launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
48
+ * Windows.
49
+ *
50
+ * `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
51
+ * after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
52
+ * A substring/regex test anywhere in the full command line is not enough: an
53
+ * `agents run claude "<prompt>"` invocation whose prompt happens to quote the
54
+ * literal text `__daemon-run` (this ticket's own brief does) matches that test
55
+ * too, and was observed producing false "duplicate daemon" rows. Requiring it
56
+ * to be the LAST whitespace-delimited token is the actual invariant.
57
+ *
58
+ * This raw box-wide scan is deliberately NOT the duplicate-detection scope
59
+ * (RUSH-2368): a `__daemon-run` under a different HOME serves a different
60
+ * `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
61
+ * `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
62
+ * this scan and was reported as a stray to `kill`. Callers attach their own
63
+ * scope: `agents daemon status` gates on the instance registry,
64
+ * {@link findLeakedDaemons} on the owner records above.
65
+ */
66
+ export function listDaemonRunProcesses() {
67
+ if (process.platform === 'win32')
68
+ return [];
69
+ let out;
70
+ try {
71
+ out = execFileSync('ps', ['-eo', 'pid=,uid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const found = [];
77
+ for (const line of out.split('\n')) {
78
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
79
+ if (!m)
80
+ continue;
81
+ const tokens = m[3].trim().split(/\s+/);
82
+ if (tokens.length === 0 || tokens[tokens.length - 1] !== '__daemon-run')
83
+ continue;
84
+ const pid = parseInt(m[1], 10);
85
+ if (isNaN(pid))
86
+ continue;
87
+ const uidParsed = parseInt(m[2], 10);
88
+ found.push({ pid, uid: isNaN(uidParsed) ? null : uidParsed, tokens });
89
+ }
90
+ return found;
91
+ }
92
+ /**
93
+ * The HOME of a live process, or null when unreadable. Linux reads
94
+ * `/proc/<pid>/environ`; macOS has no equivalent zero-dependency primitive, so
95
+ * it falls back to `ps -E`, which appends the environment to the command
96
+ * column. Both are best-effort: a null HOME must never turn into an accusation
97
+ * beyond "unknown".
98
+ */
99
+ function processHome(pid) {
100
+ if (process.platform === 'linux') {
101
+ try {
102
+ const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf-8');
103
+ for (const entry of env.split('\0')) {
104
+ if (entry.startsWith('HOME='))
105
+ return entry.slice('HOME='.length) || null;
106
+ }
107
+ return null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ try {
114
+ const out = execFileSync('ps', ['-E', '-o', 'command=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
115
+ const m = out.match(/(?:^|\s)HOME=(\S+)/);
116
+ return m ? m[1] : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ /** The start time of a live process as `ps lstart` renders it, or null (best-effort, POSIX only). */
123
+ function processStartTime(pid) {
124
+ if (process.platform === 'win32')
125
+ return null;
126
+ try {
127
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
128
+ return out || null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Every `__daemon-run` process running as THIS uid that no owner record names:
136
+ * neither the service manager's unit main PID nor the recorded
137
+ * `<daemonDir>/daemon.pid`. Each result carries the process's HOME and start
138
+ * time so the report can show them. An empty list means no leak — including
139
+ * the common stopped-daemon case, where there is simply nothing running.
140
+ */
141
+ export function findLeakedDaemons() {
142
+ const owned = new Set();
143
+ const recorded = readDaemonPid();
144
+ if (recorded)
145
+ owned.add(recorded);
146
+ const unitPid = readServiceManagerPid();
147
+ if (unitPid)
148
+ owned.add(unitPid);
149
+ // A caller under a redirected HOME (a test/e2e harness) must still recognize
150
+ // the REAL install's daemon as owned: its records live under the account
151
+ // home (`os.userInfo().homedir` reads the passwd record, ignoring $HOME),
152
+ // not under this process's HOME. Flagging the box's healthy production
153
+ // daemon as a leak would be the RUSH-2368 harm through a new door.
154
+ const realDaemonDir = getDaemonDirForHome(os.userInfo().homedir);
155
+ if (realDaemonDir !== getDaemonDir()) {
156
+ const realRecorded = readDaemonPid(realDaemonDir);
157
+ if (realRecorded)
158
+ owned.add(realRecorded);
159
+ const realUnitPid = readServiceManagerPid(os.platform(), productionDaemonServiceNames());
160
+ if (realUnitPid)
161
+ owned.add(realUnitPid);
162
+ }
163
+ const myUid = typeof process.getuid === 'function' ? process.getuid() : null;
164
+ const leaked = [];
165
+ for (const p of listDaemonRunProcesses()) {
166
+ if (owned.has(p.pid))
167
+ continue;
168
+ // Another uid's daemon is never named: we cannot read its environment
169
+ // reliably and could not signal it if we tried.
170
+ if (myUid !== null && p.uid !== null && p.uid !== myUid)
171
+ continue;
172
+ leaked.push({
173
+ pid: p.pid,
174
+ home: processHome(p.pid),
175
+ startedAt: processStartTime(p.pid),
176
+ entry: p.tokens.length >= 2 ? p.tokens[p.tokens.length - 2] : null,
177
+ });
178
+ }
179
+ return leaked;
180
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentId } from '../types.js';
2
2
  import type { DuplicateVersionHook } from '../hooks/install.js';
3
3
  import type { AgentsBinaryShadow } from '../binary-shadow.js';
4
+ import type { LeakedDaemon } from '../daemon/leaked-daemons.js';
4
5
  import type { RcSecretFinding } from '../secrets-types.js';
5
6
  import type { OwnerSinkStatus } from '../channels/owner-sink.js';
6
7
  import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
@@ -13,7 +14,7 @@ export type FindingSeverity = 'critical' | 'warning';
13
14
  * the JSON consumer group by kind. */
14
15
  /** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
15
16
  * below owns it, and a second copy in these comments is a fourth place to drift. */
16
- export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable"];
17
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "auth-bundle-wrong-backend", "exec-policy", "ssh-key-enrollment", "stale-cli", "binary-shadow", "owner-sink-unreachable", "leaked-daemon"];
17
18
  /**
18
19
  * The severity each kind is emitted with - the SINGLE source of truth, read by
19
20
  * the builders below and asserted against both prose rubrics by
@@ -124,6 +125,11 @@ export interface LocalFindingInputs {
124
125
  ownerSink?: OwnerSinkStatus;
125
126
  /** `agents` binaries that shadow the currently running CLI (RUSH-2431). */
126
127
  binaryShadows?: AgentsBinaryShadow[];
128
+ /** `__daemon-run` processes no owner record names — neither the service
129
+ * manager's unit main PID nor the recorded daemon.pid (W4, PHNX-3736).
130
+ * Collected by `findLeakedDaemons` in the command (it runs `ps` and reads
131
+ * /proc, so it stays out of this pure module). */
132
+ leakedDaemons?: LeakedDaemon[];
127
133
  }
128
134
  /**
129
135
  * Fold this machine's signals into findings. Missing hooks/plugins and unwired
@@ -27,7 +27,7 @@
27
27
  * fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
28
28
  * duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
29
29
  * rc-secret-export · env-secret-export · auth-bundle-wrong-backend · exec-policy · stale-cli ·
30
- * binary-shadow.
30
+ * binary-shadow · leaked-daemon.
31
31
  * (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
32
32
  * stale-sync states one `agents sync` resolves, not "needs you now".)
33
33
  *
@@ -38,6 +38,7 @@
38
38
  * format, so the layout is unit-tested against fixtures with no live fleet.
39
39
  */
40
40
  import chalk from 'chalk';
41
+ import * as path from 'path';
41
42
  import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
42
43
  import { blocksLocalScripts } from '../platform/winpath.js';
43
44
  import { loginHint } from '../signin-badge.js';
@@ -126,6 +127,7 @@ export const ALL_FINDING_KINDS = [
126
127
  'stale-cli',
127
128
  'binary-shadow', // another agents binary on PATH or in a well-known dir shadows the running copy
128
129
  'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
130
+ 'leaked-daemon', // a `__daemon-run` process no owner record names (not the unit main PID, not daemon.pid)
129
131
  ];
130
132
  /**
131
133
  * The severity each kind is emitted with - the SINGLE source of truth, read by
@@ -174,6 +176,12 @@ export const FINDING_SEVERITY = {
174
176
  'ssh-key-enrollment': 'critical',
175
177
  'stale-cli': 'warning',
176
178
  'binary-shadow': 'warning',
179
+ // A stray `__daemon-run` under its own (usually temp) HOME: it shares nothing
180
+ // with this install's state, so it cannot double-fire the real box's work —
181
+ // but it is a leak nothing owns and it outlives whatever launched it (W4,
182
+ // PHNX-3736). A human reads its HOME/start time and kills it; nothing is
183
+ // blocked right now.
184
+ 'leaked-daemon': 'warning',
177
185
  };
178
186
  function agentName(agent) {
179
187
  return AGENT_NAMES[agent] || agent;
@@ -305,6 +313,11 @@ export function remediationFor(finding) {
305
313
  return 'upgrade';
306
314
  case 'binary-shadow':
307
315
  return 'remove or repoint the shadowing agents install(s)';
316
+ case 'leaked-daemon':
317
+ // The builder emits the concrete `kill <pid>` — the pid is a property of
318
+ // the row it builds, not of anything remediationFor can see. This is the
319
+ // shape only, for a kind constructed through finding() by a future caller.
320
+ return 'kill <pid>';
308
321
  case 'owner-sink-unreachable':
309
322
  // The lane delivers over the rush-backed owner channel, which needs rush on
310
323
  // PATH AND a usable session in THIS context. Non-interactive shells miss a
@@ -579,6 +592,24 @@ export function buildLocalFindings(input) {
579
592
  : `${shadows.length} agents binaries may shadow the running copy (incl. ${examples})`,
580
593
  }));
581
594
  }
595
+ // Leaked daemons (warning) — a `__daemon-run` no owner record names. One row
596
+ // per pid: the remediation is pid-specific, and the HOME + start time are
597
+ // what let the operator tell a leaked test fixture from something they meant
598
+ // to run before they kill it (W4, PHNX-3736). Built directly, not through
599
+ // finding(): the remediation carries this row's own pid.
600
+ for (const d of input.leakedDaemons ?? []) {
601
+ const home = d.home ?? 'unknown HOME';
602
+ const started = d.startedAt ? `, started ${d.startedAt}` : '';
603
+ // The launch entry names WHICH install leaked the daemon — part of what
604
+ // the operator judges before killing. Only a real path: for a stand-in
605
+ // (`node -e '<code>' __daemon-run`) the token is the code blob.
606
+ const entry = d.entry && path.isAbsolute(d.entry) ? ` · ${d.entry}` : '';
607
+ out.push({
608
+ severity: FINDING_SEVERITY['leaked-daemon'], kind: 'leaked-daemon', device,
609
+ message: `stray agents daemon (pid ${d.pid}) no unit or pid file owns — HOME=${home}${started}${entry}`,
610
+ remediation: `kill ${d.pid}`,
611
+ });
612
+ }
582
613
  return collapseAcrossVersions(out, new Set(input.isolatedVersions ?? []));
583
614
  }
584
615
  /**
@@ -2256,12 +2256,72 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
2256
2256
  }
2257
2257
  return removed;
2258
2258
  }
2259
+ function trustCodexHooks(hooksPath) {
2260
+ const configPath = path.join(path.dirname(hooksPath), 'config.toml');
2261
+ const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
2262
+ let tomlConfig = {};
2263
+ if (fs.existsSync(configPath)) {
2264
+ tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
2265
+ }
2266
+ if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
2267
+ tomlConfig.features = {};
2268
+ }
2269
+ // Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
2270
+ // an unrecognized key that triggers a deprecation error and is ignored).
2271
+ const features = tomlConfig.features;
2272
+ delete features.codex_hooks;
2273
+ features.hooks = true;
2274
+ // Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
2275
+ // exactly as Codex resolves it (the absolute CODEX_HOME path), the
2276
+ // snake_case event label, and the per-event group/handler indices — which
2277
+ // must match Codex's parse order, so we iterate the just-written
2278
+ // hooksFile structure in array order.
2279
+ if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
2280
+ tomlConfig.hooks = {};
2281
+ }
2282
+ const hooksTable = tomlConfig.hooks;
2283
+ const existingState = hooksTable.state && typeof hooksTable.state === 'object'
2284
+ ? hooksTable.state
2285
+ : {};
2286
+ const hookState = {};
2287
+ for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
2288
+ const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
2289
+ if (!eventKeyLabel)
2290
+ continue;
2291
+ eventGroups.forEach((group, groupIdx) => {
2292
+ if (!group.hooks)
2293
+ return;
2294
+ group.hooks.forEach((handler, handlerIdx) => {
2295
+ if (handler.type !== 'command')
2296
+ return;
2297
+ const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
2298
+ const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
2299
+ // Preserve a user's explicit `enabled = false` for this exact hook;
2300
+ // only (re)write the trust hash.
2301
+ const prior = existingState[key];
2302
+ const entry = { trusted_hash: trustedHash };
2303
+ if (prior && prior.enabled === false) {
2304
+ entry.enabled = false;
2305
+ }
2306
+ hookState[key] = entry;
2307
+ });
2308
+ });
2309
+ }
2310
+ // Carry forward trust state for any hooks we did not (re)register this
2311
+ // pass — e.g. user-added hooks under a different command path.
2312
+ for (const [key, entry] of Object.entries(existingState)) {
2313
+ if (!(key in hookState)) {
2314
+ hookState[key] = entry;
2315
+ }
2316
+ }
2317
+ hooksTable.state = hookState;
2318
+ fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
2319
+ }
2259
2320
  function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
2260
2321
  const registered = [];
2261
2322
  const errors = [];
2262
2323
  const configDir = path.join(versionHome, '.codex');
2263
2324
  const hooksPath = path.join(configDir, 'hooks.json');
2264
- const configPath = path.join(configDir, 'config.toml');
2265
2325
  // Read existing hooks.json — must have top-level "hooks" wrapper key
2266
2326
  let hooksFile = { hooks: {} };
2267
2327
  if (fs.existsSync(hooksPath)) {
@@ -2373,66 +2433,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
2373
2433
  // them, so an untrusted hook is silently dropped. We compute the same
2374
2434
  // trust hash Codex would and persist it under [hooks.state].
2375
2435
  try {
2376
- let tomlConfig = {};
2377
- if (fs.existsSync(configPath)) {
2378
- try {
2379
- tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
2380
- }
2381
- catch { /* start fresh if corrupt */ }
2382
- }
2383
- if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
2384
- tomlConfig.features = {};
2385
- }
2386
- // Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
2387
- // an unrecognized key that triggers a deprecation error and is ignored).
2388
- const features = tomlConfig.features;
2389
- delete features.codex_hooks;
2390
- features.hooks = true;
2391
- // Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
2392
- // exactly as Codex resolves it (the absolute CODEX_HOME path), the
2393
- // snake_case event label, and the per-event group/handler indices — which
2394
- // must match Codex's parse order, so we iterate the just-written
2395
- // hooksFile structure in array order.
2396
- if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
2397
- tomlConfig.hooks = {};
2398
- }
2399
- const hooksTable = tomlConfig.hooks;
2400
- const existingState = hooksTable.state && typeof hooksTable.state === 'object'
2401
- ? hooksTable.state
2402
- : {};
2403
- const hookState = {};
2404
- for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
2405
- const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
2406
- if (!eventKeyLabel)
2407
- continue;
2408
- eventGroups.forEach((group, groupIdx) => {
2409
- if (!group.hooks)
2410
- return;
2411
- group.hooks.forEach((handler, handlerIdx) => {
2412
- if (handler.type !== 'command')
2413
- return;
2414
- const key = `${hooksPath}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`;
2415
- const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
2416
- // Preserve a user's explicit `enabled = false` for this exact hook;
2417
- // only (re)write the trust hash.
2418
- const prior = existingState[key];
2419
- const entry = { trusted_hash: trustedHash };
2420
- if (prior && prior.enabled === false) {
2421
- entry.enabled = false;
2422
- }
2423
- hookState[key] = entry;
2424
- });
2425
- });
2426
- }
2427
- // Carry forward trust state for any hooks we did not (re)register this
2428
- // pass — e.g. user-added hooks under a different command path.
2429
- for (const [key, entry] of Object.entries(existingState)) {
2430
- if (!(key in hookState)) {
2431
- hookState[key] = entry;
2432
- }
2433
- }
2434
- hooksTable.state = hookState;
2435
- fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
2436
+ trustCodexHooks(hooksPath);
2436
2437
  }
2437
2438
  catch (err) {
2438
2439
  errors.push(`Failed to update config.toml: ${err.message}`);
@@ -3282,11 +3283,14 @@ export async function installSessionTrackerHook(agent, version, home) {
3282
3283
  if (!invocation) {
3283
3284
  return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
3284
3285
  }
3286
+ const env = sessionTrackerInstallEnv(agent, version, home);
3285
3287
  try {
3286
3288
  await execFileAsync(invocation.command, invocation.args, {
3287
- env: sessionTrackerInstallEnv(agent, version, home),
3289
+ env,
3288
3290
  encoding: 'utf8',
3289
3291
  });
3292
+ if (agent === 'codex')
3293
+ trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
3290
3294
  return { installed: true };
3291
3295
  }
3292
3296
  catch (err) {
@@ -3324,12 +3328,15 @@ export function installSessionTrackerHookSync(agent, version, home) {
3324
3328
  if (!invocation) {
3325
3329
  return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
3326
3330
  }
3331
+ const env = sessionTrackerInstallEnv(agent, version, home);
3327
3332
  try {
3328
3333
  execFileSync(invocation.command, invocation.args, {
3329
- env: sessionTrackerInstallEnv(agent, version, home),
3334
+ env,
3330
3335
  stdio: ['ignore', 'pipe', 'pipe'],
3331
3336
  encoding: 'utf8',
3332
3337
  });
3338
+ if (agent === 'codex')
3339
+ trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
3333
3340
  return { installed: true };
3334
3341
  }
3335
3342
  catch (err) {
@@ -13,7 +13,8 @@ import { execFileSync } from 'child_process';
13
13
  import chalk from 'chalk';
14
14
  import { getVersionDir, getVersionHomePath, getBinaryPath } from './installations/versions.js';
15
15
  import { getModelsCachePath } from './state.js';
16
- import { agentConfigDirName } from './agents.js';
16
+ import { agentConfigDirName, resolveOpenCodeXdgPath } from './agents.js';
17
+ import { stripJsonComments } from './permissions-registry.js';
17
18
  import { resolveRunDefaults } from './run-defaults.js';
18
19
  import { getModelPricing } from './pricing/index.js';
19
20
  const CACHE_PATH = getModelsCachePath();
@@ -1014,6 +1015,12 @@ export function resolveConfiguredModel(agent, version, home) {
1014
1015
  const nativeModel = readNativeConfigModel(agent, version, home);
1015
1016
  if (nativeModel)
1016
1017
  return { model: nativeModel, source: 'config' };
1018
+ // The agent's own persisted selection, for a runtime that stores one instead
1019
+ // of flagging a catalog default. Ranks below `config` (an explicit setting
1020
+ // wins) and above the catalog, whose `isDefault` OpenCode never sets.
1021
+ const selected = readNativeSelectedModel(agent, version);
1022
+ if (selected)
1023
+ return { model: selected, source: 'cli-default' };
1017
1024
  const catalog = getModelCatalog(agent, version);
1018
1025
  if (catalog) {
1019
1026
  const flagged = catalog.models.find((m) => m.isDefault);
@@ -1022,15 +1029,79 @@ export function resolveConfiguredModel(agent, version, home) {
1022
1029
  return null;
1023
1030
  }
1024
1031
  /**
1025
- * Best-effort read of the agent's own `model` from its native settings.json
1032
+ * Where each agent keeps its OWN `model` setting, when that is not the
1033
+ * `<configDir>/settings.json` every Claude-shaped harness uses.
1034
+ *
1035
+ * OpenCode reads `~/.config/opencode/opencode.{jsonc,json}` and puts `model` at
1036
+ * its top level as `"<provider>/<model-id>"`. Its `~/.opencode/settings.json`
1037
+ * DOES exist, but that is agents-cli's own plugin-enablement file, which
1038
+ * OpenCode never reads a model from — so the default path found nothing and
1039
+ * every OpenCode row rendered the placeholder `default`. Comments are stripped
1040
+ * for BOTH spellings because OpenCode's loader accepts them in both, which is
1041
+ * also how the MCP writer treats the same file (`format: 'opencode-jsonc'`).
1042
+ */
1043
+ const NATIVE_MODEL_CONFIGS = {
1044
+ opencode: {
1045
+ paths: (home) => [
1046
+ path.join(home, '.config', 'opencode', 'opencode.jsonc'),
1047
+ path.join(home, '.config', 'opencode', 'opencode.json'),
1048
+ ],
1049
+ jsonc: true,
1050
+ },
1051
+ };
1052
+ /**
1053
+ * Best-effort read of the agent's own `model` from its native config
1026
1054
  * (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
1027
1055
  * A missing/malformed file is a fall-through, not an error.
1028
1056
  */
1029
1057
  function readNativeConfigModel(agent, version, home) {
1058
+ const resolvedHome = home ?? getVersionHomePath(agent, version);
1059
+ const native = NATIVE_MODEL_CONFIGS[agent];
1060
+ const candidates = native?.paths(resolvedHome)
1061
+ ?? [path.join(resolvedHome, agentConfigDirName(agent), 'settings.json')];
1062
+ for (const configPath of candidates) {
1063
+ try {
1064
+ const raw = fs.readFileSync(configPath, 'utf8');
1065
+ const parsed = JSON.parse(native?.jsonc ? stripJsonComments(raw) : raw);
1066
+ if (typeof parsed.model === 'string' && parsed.model.trim() !== '')
1067
+ return parsed.model;
1068
+ }
1069
+ catch {
1070
+ /* absent or malformed — try the next spelling */
1071
+ }
1072
+ }
1073
+ return null;
1074
+ }
1075
+ /**
1076
+ * The model the agent's OWN runtime will start with, for a harness that persists
1077
+ * its selection rather than flagging a catalog default.
1078
+ *
1079
+ * OpenCode is the case that needs it: it ships no default model, so its catalog
1080
+ * has no `isDefault` and `agents view` fell through to the literal `default` —
1081
+ * a placeholder, not something the user could act on. OpenCode instead records
1082
+ * the model picked in its TUI to `$XDG_STATE_HOME/opencode/model.json` as
1083
+ * `{ recent: [{ providerID, modelID }, …] }`, newest first, and reuses
1084
+ * `recent[0]` for the next session. That entry is the real answer to "what model
1085
+ * is this install on", rendered the same `<provider>/<model-id>` way OpenCode's
1086
+ * own config spells it.
1087
+ *
1088
+ * Returns null for every other agent, and for OpenCode when nothing has been
1089
+ * selected yet — the catalog fallback still applies.
1090
+ */
1091
+ function readNativeSelectedModel(agent, version) {
1092
+ if (agent !== 'opencode')
1093
+ return null;
1094
+ const statePath = resolveOpenCodeXdgPath(getVersionHomePath(agent, version), 'state', 'model.json');
1095
+ if (!statePath)
1096
+ return null;
1030
1097
  try {
1031
- const settingsPath = path.join(home ?? getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
1032
- const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1033
- return typeof parsed.model === 'string' && parsed.model.trim() !== '' ? parsed.model : null;
1098
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
1099
+ const current = parsed.recent?.[0];
1100
+ const provider = typeof current?.providerID === 'string' ? current.providerID : '';
1101
+ const model = typeof current?.modelID === 'string' ? current.modelID : '';
1102
+ if (!model)
1103
+ return null;
1104
+ return provider ? `${provider}/${model}` : model;
1034
1105
  }
1035
1106
  catch {
1036
1107
  return null;
@@ -53,6 +53,8 @@ function agentToFormat(agent) {
53
53
  return 'rush';
54
54
  if (agent === 'droid')
55
55
  return 'droid';
56
+ if (agent === 'opencode')
57
+ return 'opencode';
56
58
  return null;
57
59
  }
58
60
  export function assertContained(candidate, rootDir) {
@@ -128,7 +130,7 @@ export async function ensureCloudSessionCached(executionId, destPath) {
128
130
  throw new Error(`session.jsonl fetch ${res.status}: ${body.slice(0, 200)}`);
129
131
  }
130
132
  const format = (res.headers.get('X-Session-Format') || '').toLowerCase();
131
- if (!['claude', 'codex', 'rush'].includes(format)) {
133
+ if (!['claude', 'codex', 'rush', 'opencode'].includes(format)) {
132
134
  throw new Error(`Unknown X-Session-Format on cloud response: "${format}"`);
133
135
  }
134
136
  const finalPath = callerPath ?? cachePathForExecution(id, format);
@@ -229,6 +229,7 @@ export declare function parseGrok(filePath: string): SessionEvent[];
229
229
  */
230
230
  export declare const OPENCODE_TRANSCRIPT_QUERY: string;
231
231
  export declare function parseOpenCode(filePath: string): SessionEvent[];
232
+ export declare function parseOpencodeCloud(filePath: string): SessionEvent[];
232
233
  /** Parse a Rush JSONL session file into normalized events. */
233
234
  export declare function parseRush(filePath: string): SessionEvent[];
234
235
  /**