@phnx-labs/agents-cli 1.22.106 → 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 (44) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +9 -5
  3. package/dist/commands/daemon.js +45 -40
  4. package/dist/commands/doctor.js +7 -0
  5. package/dist/commands/exec.d.ts +78 -8
  6. package/dist/commands/exec.js +276 -53
  7. package/dist/commands/monitors.js +15 -2
  8. package/dist/commands/routines.js +46 -6
  9. package/dist/commands/run-account-picker.d.ts +11 -0
  10. package/dist/commands/run-account-picker.js +11 -1
  11. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  12. package/dist/commands/sessions-backup-setup.js +65 -0
  13. package/dist/commands/sessions-resume.d.ts +14 -1
  14. package/dist/commands/sessions-resume.js +56 -9
  15. package/dist/commands/sessions.js +2 -0
  16. package/dist/commands/share.js +15 -2
  17. package/dist/commands/sync.js +30 -1
  18. package/dist/lib/accounts/slots.js +7 -0
  19. package/dist/lib/agent-spec/agents.d.ts +60 -8
  20. package/dist/lib/agent-spec/agents.js +118 -45
  21. package/dist/lib/daemon/daemon.d.ts +34 -1
  22. package/dist/lib/daemon/daemon.js +63 -9
  23. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  24. package/dist/lib/daemon/leaked-daemons.js +180 -0
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  26. package/dist/lib/devices/doctor-findings.js +32 -1
  27. package/dist/lib/hooks/install.js +70 -63
  28. package/dist/lib/hosts/dispatch.d.ts +1 -1
  29. package/dist/lib/hosts/dispatch.js +1 -1
  30. package/dist/lib/models.js +76 -5
  31. package/dist/lib/session/cloud.js +3 -1
  32. package/dist/lib/session/parse.d.ts +1 -0
  33. package/dist/lib/session/parse.js +136 -2
  34. package/dist/lib/session/recovery.d.ts +9 -1
  35. package/dist/lib/session/recovery.js +14 -4
  36. package/dist/lib/session/tool-calls.d.ts +1 -1
  37. package/dist/lib/session/tool-calls.js +40 -5
  38. package/dist/lib/share/backend.d.ts +23 -0
  39. package/dist/lib/share/backend.js +24 -0
  40. package/dist/lib/share/provision.d.ts +12 -0
  41. package/dist/lib/share/provision.js +30 -0
  42. package/dist/lib/share/worker-template.js +273 -0
  43. package/dist/lib/terminal/engine.js +13 -1
  44. package/package.json +1 -1
@@ -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) {
@@ -182,7 +182,7 @@ export interface InteractiveDispatchOptions {
182
182
  agent: string;
183
183
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
184
184
  version?: string;
185
- /** Preserve the trailing-@ account picker so selection happens on the execution host. */
185
+ /** Preserve the trailing-# account picker so selection happens on the execution host. */
186
186
  accountPicker?: boolean;
187
187
  /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
188
188
  strategy?: string;
@@ -362,7 +362,7 @@ async function launchDetached(host, target, opts) {
362
362
  /** Compose `agent[@version][#account]` so the peer resolves ITS slot (PHNX-3940 T5). */
363
363
  export function runAgentSpecArg(opts) {
364
364
  if (opts.accountPicker)
365
- return `${opts.agent}@`;
365
+ return `${opts.agent}#`;
366
366
  let spec = opts.agent;
367
367
  if (opts.version)
368
368
  spec += `@${opts.version}`;
@@ -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
  /**
@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
  import Database from '../sqlite.js';
13
13
  import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
14
14
  import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
15
+ import { isCloudSessionPath } from './cloud.js';
15
16
  /**
16
17
  * Largest session file we will load into memory. Above this we throw a clean
17
18
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -143,7 +144,11 @@ const TRANSCRIPT_PARSERS = {
143
144
  codex: (filePath) => parseCodex(filePath),
144
145
  gemini: (filePath) => parseGemini(filePath),
145
146
  antigravity: (filePath) => parseAntigravity(filePath),
146
- opencode: (filePath) => parseOpenCode(filePath),
147
+ // Cloud-captured opencode sessions are normalized JSONL (produced by the
148
+ // factory at capture time), NOT the local `opencode.db#<session>` SQLite
149
+ // composite the offline parser reads — route by whether the path is in the
150
+ // cloud session cache (PHNX-3845).
151
+ opencode: (filePath) => isCloudSessionPath(filePath) ? parseOpencodeCloud(filePath) : parseOpenCode(filePath),
147
152
  grok: (filePath) => parseGrok(filePath),
148
153
  rush: (filePath) => parseRush(filePath),
149
154
  openclaw: () => [], // OpenClaw sessions don't have parseable files yet
@@ -206,7 +211,7 @@ export function detectAgent(filePath) {
206
211
  return 'muse';
207
212
  }
208
213
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
209
- const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
214
+ const cloudMatch = filePath.match(/session\.(claude|codex|rush|opencode)\.jsonl(?:$|[?#])/);
210
215
  if (cloudMatch)
211
216
  return cloudMatch[1];
212
217
  if (filePath.includes('opencode.db'))
@@ -1832,6 +1837,135 @@ export function parseOpenCode(filePath) {
1832
1837
  return events;
1833
1838
  }
1834
1839
  // ---------------------------------------------------------------------------
1840
+ // OpenCode parser — cloud (normalized JSONL)
1841
+ // ---------------------------------------------------------------------------
1842
+ //
1843
+ // OpenCode stores sessions in a SQLite DB, so the offline path reads a
1844
+ // `opencode.db#<session>` composite (parseOpenCode above). The CLOUD path is
1845
+ // different: the factory converts that DB into a flat, normalized JSONL
1846
+ // transcript at capture time (prix/factory opencode-capture.ts, PHNX-3845), so
1847
+ // the whole cloud pipeline stays SQLite-free downstream. This parser reads that
1848
+ // JSONL; it's routed from TRANSCRIPT_PARSERS only when the file lives in the
1849
+ // cloud session cache (isCloudSessionPath).
1850
+ //
1851
+ // Each line is one of:
1852
+ // transcript row:
1853
+ // { role: "user"|"assistant", part_type: "text"|"reasoning"|"tool",
1854
+ // part_data: <json string>, time_created: <ms number> }
1855
+ // todo snapshot (at most one, emitted last):
1856
+ // { part_type: "todo", todos: [{content,status}], time_created: <ms> }
1857
+ //
1858
+ // The per-part logic mirrors parseOpenCode's post-query switch (and prix/api's
1859
+ // server-side parseOpencode) so the event stream is identical to the offline
1860
+ // path. NOTE: the shell tool is named `bash` on real opencode (>=1.18.x), not
1861
+ // `shell` — map `command` for both so a shell step reads by its command line.
1862
+ export function parseOpencodeCloud(filePath) {
1863
+ const content = safeReadSessionFile(filePath);
1864
+ const lines = content.split('\n').filter(l => l.trim());
1865
+ const events = [];
1866
+ for (const line of lines) {
1867
+ let raw;
1868
+ try {
1869
+ raw = JSON.parse(line);
1870
+ }
1871
+ catch {
1872
+ /* malformed JSONL line, skip */
1873
+ continue;
1874
+ }
1875
+ const partType = typeof raw.part_type === 'string' ? raw.part_type : '';
1876
+ const timeMs = typeof raw.time_created === 'number'
1877
+ ? raw.time_created
1878
+ : parseInt(String(raw.time_created), 10);
1879
+ const timestamp = Number.isFinite(timeMs)
1880
+ ? new Date(timeMs).toISOString()
1881
+ : new Date().toISOString();
1882
+ // Todo snapshot: emit one `todo_write` tool_use so the shared enrichment
1883
+ // (`extractTodoProgressFromEvents`) derives `todos` the same way it does for
1884
+ // every other harness — the offline parser emits the identical event.
1885
+ if (partType === 'todo') {
1886
+ const todos = Array.isArray(raw.todos) ? raw.todos : [];
1887
+ if (todos.length) {
1888
+ events.push({
1889
+ type: 'tool_use',
1890
+ agent: 'opencode',
1891
+ timestamp,
1892
+ tool: 'todo_write',
1893
+ args: { todos },
1894
+ });
1895
+ }
1896
+ continue;
1897
+ }
1898
+ let partData;
1899
+ try {
1900
+ partData = JSON.parse(typeof raw.part_data === 'string' ? raw.part_data : '');
1901
+ }
1902
+ catch {
1903
+ /* malformed part data, skip */
1904
+ continue;
1905
+ }
1906
+ switch (partType) {
1907
+ case 'text': {
1908
+ const text = (partData.text || '').trim();
1909
+ if (text) {
1910
+ events.push({
1911
+ type: 'message',
1912
+ agent: 'opencode',
1913
+ timestamp,
1914
+ role: raw.role === 'user' ? 'user' : 'assistant',
1915
+ content: text,
1916
+ });
1917
+ }
1918
+ break;
1919
+ }
1920
+ case 'reasoning': {
1921
+ const text = (partData.text || '').trim();
1922
+ if (text) {
1923
+ events.push({
1924
+ type: 'thinking',
1925
+ agent: 'opencode',
1926
+ timestamp,
1927
+ content: text,
1928
+ });
1929
+ }
1930
+ break;
1931
+ }
1932
+ case 'tool': {
1933
+ const toolName = partData.tool || 'unknown';
1934
+ const state = partData.state || {};
1935
+ const input = state.input || {};
1936
+ const output = state.output || '';
1937
+ const callId = typeof partData.callID === 'string' ? partData.callID : undefined;
1938
+ events.push({
1939
+ type: 'tool_use',
1940
+ agent: 'opencode',
1941
+ timestamp,
1942
+ tool: toolName,
1943
+ callId,
1944
+ args: input,
1945
+ command: toolName === 'bash' || toolName === 'shell' ? input.command : undefined,
1946
+ path: input.filePath || input.path || undefined,
1947
+ });
1948
+ if (state.status === 'completed' || state.status === 'error') {
1949
+ const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
1950
+ events.push({
1951
+ type: state.status === 'error' ? 'error' : 'tool_result',
1952
+ agent: 'opencode',
1953
+ timestamp,
1954
+ tool: toolName,
1955
+ callId,
1956
+ success: state.status === 'completed',
1957
+ output: outputStr,
1958
+ });
1959
+ }
1960
+ break;
1961
+ }
1962
+ // step-start / step-finish / patch / file never reach the JSONL (dropped
1963
+ // at capture time) — nothing to handle here.
1964
+ }
1965
+ }
1966
+ return events;
1967
+ }
1968
+ // ---------------------------------------------------------------------------
1835
1969
  // Rush parser
1836
1970
  //
1837
1971
  // Rush messages.jsonl format is flat: one JSON object per line with
@@ -68,7 +68,15 @@ export declare function sessionMatchesAccount(session: Pick<SessionMeta, 'agent'
68
68
  export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
69
69
  /** Keep the native conversation where possible; replay remains a separate choice. */
70
70
  export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection, options?: SessionRecoverySelection): SessionRecoveryTarget;
71
- /** A mirror digest or live registry entry is not conversation content. */
71
+ /**
72
+ * Whether recovery has conversation content to replay for this session: a
73
+ * non-empty transcript file, or archived content in the index for a row this
74
+ * device owns. A mirror digest or live registry entry is not conversation
75
+ * content. The picker consults this before spending a terminal tab on a pick
76
+ * that {@link assertRecoverableTranscript} would refuse one hop later.
77
+ */
78
+ export declare function sessionTranscriptReadable(session: SessionMeta): boolean;
79
+ /** Refuse recovery for a session with nothing to replay (PHNX-4080). */
72
80
  export declare function assertRecoverableTranscript(session: SessionMeta): void;
73
81
  /**
74
82
  * Resolve recovery for a durable session, reading the live account pool.
@@ -365,16 +365,26 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
365
365
  reason: `${sourceReason(session, source, options.model ?? session.model)}; continuing with ${continueWith}`,
366
366
  };
367
367
  }
368
- /** A mirror digest or live registry entry is not conversation content. */
369
- export function assertRecoverableTranscript(session) {
368
+ /**
369
+ * Whether recovery has conversation content to replay for this session: a
370
+ * non-empty transcript file, or archived content in the index for a row this
371
+ * device owns. A mirror digest or live registry entry is not conversation
372
+ * content. The picker consults this before spending a terminal tab on a pick
373
+ * that {@link assertRecoverableTranscript} would refuse one hop later.
374
+ */
375
+ export function sessionTranscriptReadable(session) {
370
376
  const file = splitSessionFilePath(session.filePath).container;
371
377
  try {
372
378
  if (file && fs.statSync(file).isFile() && fs.statSync(file).size > 0
373
379
  && (session.agent !== 'opencode' || parseOpenCode(session.filePath).length > 0))
374
- return;
380
+ return true;
375
381
  }
376
382
  catch { /* The canonical scan already tried to repair this path. */ }
377
- if (!session.mirrorSyncedAt && !session.mirrorSource && readSessionContent(session.id)?.trim())
383
+ return !session.mirrorSyncedAt && !session.mirrorSource && Boolean(readSessionContent(session.id)?.trim());
384
+ }
385
+ /** Refuse recovery for a session with nothing to replay (PHNX-4080). */
386
+ export function assertRecoverableTranscript(session) {
387
+ if (sessionTranscriptReadable(session))
378
388
  return;
379
389
  throw new SessionRecoveryError(`Session ${session.shortId} has no readable transcript after checking its account homes and index. ` +
380
390
  `No agent was started. Start a new conversation with: agents run ${session.agent}`);