@phnx-labs/agents-cli 1.22.107 → 1.22.110

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 (95) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/daemon.js +45 -40
  7. package/dist/commands/doctor.js +7 -0
  8. package/dist/commands/exec.js +4 -2
  9. package/dist/commands/fork.js +6 -4
  10. package/dist/commands/logs.js +2 -1
  11. package/dist/commands/monitors.js +15 -2
  12. package/dist/commands/routines.js +46 -6
  13. package/dist/commands/sessions-backfill.d.ts +30 -0
  14. package/dist/commands/sessions-backfill.js +72 -0
  15. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  16. package/dist/commands/sessions-backup-setup.js +65 -0
  17. package/dist/commands/sessions-inject.d.ts +3 -3
  18. package/dist/commands/sessions-inject.js +5 -11
  19. package/dist/commands/sessions-picker.js +10 -7
  20. package/dist/commands/sessions-resume.d.ts +17 -2
  21. package/dist/commands/sessions-resume.js +88 -12
  22. package/dist/commands/sessions.js +46 -24
  23. package/dist/commands/share.js +15 -2
  24. package/dist/commands/sync.js +30 -1
  25. package/dist/commands/utils.d.ts +9 -0
  26. package/dist/commands/utils.js +18 -0
  27. package/dist/commands/watchdog.js +4 -1
  28. package/dist/lib/accounting/rotate.d.ts +9 -0
  29. package/dist/lib/accounting/rotate.js +17 -1
  30. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  31. package/dist/lib/accounting/usage-sync.js +70 -6
  32. package/dist/lib/accounting/usage.d.ts +32 -0
  33. package/dist/lib/accounting/usage.js +37 -2
  34. package/dist/lib/accounts/slots.js +7 -0
  35. package/dist/lib/agent-spec/agents.d.ts +60 -8
  36. package/dist/lib/agent-spec/agents.js +118 -45
  37. package/dist/lib/auth-health.js +19 -0
  38. package/dist/lib/claude-statusline.js +5 -0
  39. package/dist/lib/computer/sessions-list.js +2 -1
  40. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  41. package/dist/lib/daemon/auth-sync-service.js +2 -2
  42. package/dist/lib/daemon/daemon.d.ts +34 -1
  43. package/dist/lib/daemon/daemon.js +70 -9
  44. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  45. package/dist/lib/daemon/leaked-daemons.js +180 -0
  46. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  47. package/dist/lib/daemon/session-title-service.js +76 -0
  48. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  49. package/dist/lib/daemon/usage-sync-service.js +10 -2
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +5 -0
  52. package/dist/lib/daemon-ticks.js +6 -0
  53. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  54. package/dist/lib/devices/doctor-findings.js +32 -1
  55. package/dist/lib/fleet-shared-state.d.ts +2 -0
  56. package/dist/lib/hooks/install.js +78 -64
  57. package/dist/lib/mailbox-target.js +2 -1
  58. package/dist/lib/models.js +76 -5
  59. package/dist/lib/session/active.d.ts +93 -16
  60. package/dist/lib/session/active.js +80 -14
  61. package/dist/lib/session/cloud.js +3 -1
  62. package/dist/lib/session/db.d.ts +44 -1
  63. package/dist/lib/session/db.js +122 -17
  64. package/dist/lib/session/fork.d.ts +10 -2
  65. package/dist/lib/session/fork.js +11 -2
  66. package/dist/lib/session/live-metadata.js +6 -0
  67. package/dist/lib/session/mirror.d.ts +3 -2
  68. package/dist/lib/session/mirror.js +5 -2
  69. package/dist/lib/session/parse.d.ts +1 -0
  70. package/dist/lib/session/parse.js +136 -2
  71. package/dist/lib/session/recovery.d.ts +9 -1
  72. package/dist/lib/session/recovery.js +14 -4
  73. package/dist/lib/session/remote/watch.js +18 -8
  74. package/dist/lib/session/title.d.ts +256 -0
  75. package/dist/lib/session/title.js +312 -0
  76. package/dist/lib/session/tool-calls.d.ts +1 -1
  77. package/dist/lib/session/tool-calls.js +40 -5
  78. package/dist/lib/session/tool-index.d.ts +5 -0
  79. package/dist/lib/session/tool-index.js +1 -0
  80. package/dist/lib/session/types.d.ts +11 -0
  81. package/dist/lib/share/backend.d.ts +23 -0
  82. package/dist/lib/share/backend.js +24 -0
  83. package/dist/lib/share/provision.d.ts +12 -0
  84. package/dist/lib/share/provision.js +30 -0
  85. package/dist/lib/share/worker-template.js +273 -0
  86. package/dist/lib/startup/root-command.d.ts +2 -0
  87. package/dist/lib/startup/root-command.js +22 -0
  88. package/dist/lib/terminal/engine.js +13 -1
  89. package/dist/lib/traces/sync.js +1 -0
  90. package/dist/lib/usage-refresh.d.ts +58 -7
  91. package/dist/lib/usage-refresh.js +145 -23
  92. package/dist/lib/watchdog/runner.d.ts +3 -0
  93. package/dist/lib/watchdog/runner.js +1 -0
  94. package/dist/session-tracker/dist/install-hook.js +4 -4
  95. package/package.json +1 -1
@@ -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
  /**
@@ -21,6 +21,8 @@ export interface SessionMirrorRow {
21
21
  cwd?: string;
22
22
  topic?: string;
23
23
  label?: string;
24
+ /** The publisher's daemon-generated headline (PHNX-3797), when it has produced one. */
25
+ title?: string;
24
26
  firstUser?: string;
25
27
  lastActivity?: string;
26
28
  timestamp: string;
@@ -18,8 +18,9 @@ import { promisify } from 'util';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from '../agents.js';
20
20
  import { supports, explainSkip, capableAgents } from '../capabilities.js';
21
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir } from '../state.js';
21
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir, getHistoryDir } from '../state.js';
22
22
  import { collectSubruleHooksFromState } from '../rules/compose.js';
23
+ import { codexShortKey, resolveCodexHome } from '../codex-home.js';
23
24
  function getCentralHooksDir() { return getUserHooksDir(); }
24
25
  /**
25
26
  * Resolve a hook script's absolute path. Checks user dir first, then enabled
@@ -2256,12 +2257,73 @@ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, rem
2256
2257
  }
2257
2258
  return removed;
2258
2259
  }
2260
+ function trustCodexHooks(hooksPath) {
2261
+ const hookPaths = [...new Set([hooksPath, path.join(fs.realpathSync(path.dirname(hooksPath)), 'hooks.json')])];
2262
+ const configPath = path.join(path.dirname(hooksPath), 'config.toml');
2263
+ const hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
2264
+ let tomlConfig = {};
2265
+ if (fs.existsSync(configPath)) {
2266
+ tomlConfig = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
2267
+ }
2268
+ if (!tomlConfig.features || typeof tomlConfig.features !== 'object') {
2269
+ tomlConfig.features = {};
2270
+ }
2271
+ // Codex 0.116+ feature flag is `hooks` (the legacy `codex_hooks` name is
2272
+ // an unrecognized key that triggers a deprecation error and is ignored).
2273
+ const features = tomlConfig.features;
2274
+ delete features.codex_hooks;
2275
+ features.hooks = true;
2276
+ // Pre-trust hooks. The [hooks.state] key is keyed by the hooks.json path
2277
+ // exactly as Codex resolves it (the absolute CODEX_HOME path), the
2278
+ // snake_case event label, and the per-event group/handler indices — which
2279
+ // must match Codex's parse order, so we iterate the just-written
2280
+ // hooksFile structure in array order.
2281
+ if (!tomlConfig.hooks || typeof tomlConfig.hooks !== 'object') {
2282
+ tomlConfig.hooks = {};
2283
+ }
2284
+ const hooksTable = tomlConfig.hooks;
2285
+ const existingState = hooksTable.state && typeof hooksTable.state === 'object'
2286
+ ? hooksTable.state
2287
+ : {};
2288
+ const hookState = {};
2289
+ for (const [event, eventGroups] of Object.entries(hooksFile.hooks)) {
2290
+ const eventKeyLabel = CODEX_EVENT_KEY_LABELS[event];
2291
+ if (!eventKeyLabel)
2292
+ continue;
2293
+ eventGroups.forEach((group, groupIdx) => {
2294
+ if (!group.hooks)
2295
+ return;
2296
+ group.hooks.forEach((handler, handlerIdx) => {
2297
+ if (handler.type !== 'command')
2298
+ return;
2299
+ const keys = hookPaths.map((file) => `${file}:${eventKeyLabel}:${groupIdx}:${handlerIdx}`);
2300
+ const trustedHash = computeCodexHookTrustHash(eventKeyLabel, handler.command, handler.timeout, group.matcher);
2301
+ // Preserve a user's explicit `enabled = false` for this exact hook;
2302
+ // only (re)write the trust hash.
2303
+ const entry = { trusted_hash: trustedHash };
2304
+ if (keys.some((key) => existingState[key]?.enabled === false)) {
2305
+ entry.enabled = false;
2306
+ }
2307
+ for (const key of keys)
2308
+ hookState[key] = entry;
2309
+ });
2310
+ });
2311
+ }
2312
+ // Carry forward trust state for any hooks we did not (re)register this
2313
+ // pass — e.g. user-added hooks under a different command path.
2314
+ for (const [key, entry] of Object.entries(existingState)) {
2315
+ if (!(key in hookState)) {
2316
+ hookState[key] = entry;
2317
+ }
2318
+ }
2319
+ hooksTable.state = hookState;
2320
+ fs.writeFileSync(configPath, TOML.stringify(tomlConfig), 'utf-8');
2321
+ }
2259
2322
  function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
2260
2323
  const registered = [];
2261
2324
  const errors = [];
2262
2325
  const configDir = path.join(versionHome, '.codex');
2263
2326
  const hooksPath = path.join(configDir, 'hooks.json');
2264
- const configPath = path.join(configDir, 'config.toml');
2265
2327
  // Read existing hooks.json — must have top-level "hooks" wrapper key
2266
2328
  let hooksFile = { hooks: {} };
2267
2329
  if (fs.existsSync(hooksPath)) {
@@ -2373,66 +2435,7 @@ function registerHooksForCodex(versionHome, manifest, resolveScript, managedPref
2373
2435
  // them, so an untrusted hook is silently dropped. We compute the same
2374
2436
  // trust hash Codex would and persist it under [hooks.state].
2375
2437
  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');
2438
+ trustCodexHooks(hooksPath);
2436
2439
  }
2437
2440
  catch (err) {
2438
2441
  errors.push(`Failed to update config.toml: ${err.message}`);
@@ -3283,10 +3286,13 @@ export async function installSessionTrackerHook(agent, version, home) {
3283
3286
  return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
3284
3287
  }
3285
3288
  try {
3289
+ const env = sessionTrackerInstallEnv(agent, version, home);
3286
3290
  await execFileAsync(invocation.command, invocation.args, {
3287
- env: sessionTrackerInstallEnv(agent, version, home),
3291
+ env,
3288
3292
  encoding: 'utf8',
3289
3293
  });
3294
+ if (agent === 'codex')
3295
+ trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
3290
3296
  return { installed: true };
3291
3297
  }
3292
3298
  catch (err) {
@@ -3325,11 +3331,14 @@ export function installSessionTrackerHookSync(agent, version, home) {
3325
3331
  return { installed: false, error: 'session-tracker not built and tsx is unavailable' };
3326
3332
  }
3327
3333
  try {
3334
+ const env = sessionTrackerInstallEnv(agent, version, home);
3328
3335
  execFileSync(invocation.command, invocation.args, {
3329
- env: sessionTrackerInstallEnv(agent, version, home),
3336
+ env,
3330
3337
  stdio: ['ignore', 'pipe', 'pipe'],
3331
3338
  encoding: 'utf8',
3332
3339
  });
3340
+ if (agent === 'codex')
3341
+ trustCodexHooks(path.join(env.HOME ?? os.homedir(), '.codex', 'hooks.json'));
3333
3342
  return { installed: true };
3334
3343
  }
3335
3344
  catch (err) {
@@ -3339,5 +3348,10 @@ export function installSessionTrackerHookSync(agent, version, home) {
3339
3348
  /** The hook installer must target this account, not the current global home. */
3340
3349
  function sessionTrackerInstallEnv(agent, version, home) {
3341
3350
  const target = home ?? (version ? getVersionHomePath(agent, version) : undefined);
3351
+ if (agent === 'codex' && target && version) {
3352
+ const originHome = path.join(target, '.codex');
3353
+ const historyDir = getHistoryDir();
3354
+ resolveCodexHome(originHome, path.dirname(historyDir), codexShortKey(originHome, version, historyDir));
3355
+ }
3342
3356
  return target ? { ...process.env, HOME: target, USERPROFILE: target } : { ...process.env };
3343
3357
  }
@@ -1,3 +1,4 @@
1
+ import { sessionHeadline } from './session/title.js';
1
2
  /**
2
3
  * The mailbox id a live session's box is keyed by. Teams stamp a durable
3
4
  * `agentId` (== the Claude session id for Claude teammates); a bare run has
@@ -8,7 +9,7 @@ export function mailboxIdForActiveSession(s) {
8
9
  return s.agentId ?? s.sessionId;
9
10
  }
10
11
  function labelFor(s) {
11
- return s.label ?? s.topic ?? s.teamName ?? s.host ?? s.context;
12
+ return sessionHeadline(s) ?? s.teamName ?? s.host ?? s.context;
12
13
  }
13
14
  /**
14
15
  * Resolve `target` against the live sessions. Exact id matches win over prefix
@@ -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;
@@ -55,7 +55,15 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
59
+ /**
60
+ * Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
61
+ * row it touched. The re-derive is load-bearing: `foldRecap` runs inside
62
+ * {@link getActiveSessions}, before any caller reaches the index, so a `label`
63
+ * or `generatedTitle` that only the index knows would otherwise never reach the
64
+ * shown `title` (PHNX-3797). Doing it here rather than at each of the three
65
+ * backfill call sites is what keeps them from drifting apart.
66
+ */
59
67
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
68
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
69
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -108,13 +116,43 @@ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'c
108
116
  export type SessionPhase = 'running' | 'waiting' | 'failed' | 'done' | 'idle';
109
117
  /**
110
118
  * Which rung of the recap ladder produced a row's shown {@link ActiveSession.title}
111
- * (RUSH-3011), best-first:
112
- * - `label` — a `/rename` or harness-set label (incl. an agent-generated
113
- * title, which lands in `label`); always wins.
114
- * - `last` — the last assistant line from the transcript tail; agent-derived.
115
- * - `prompt` — the first-user-prompt topic; the last-resort fallback.
119
+ * (RUSH-3011, reshaped by PHNX-3797), best-first:
120
+ * - `label` — a `/rename` or harness-set label (incl. a harness-generated
121
+ * title, which lands in `label`); always wins.
122
+ * - `generated` — the daemon-generated session title (`generatedTitle`).
123
+ * - `prompt` — the first-user-prompt topic; the honest fallback while the
124
+ * titler has not reached this session.
125
+ *
126
+ * There is deliberately no rung for the agent's last transcript line: it is a
127
+ * rolling monologue, not what the session IS. It stays on the row as
128
+ * `lastAgentLine` for the separate live preview.
116
129
  */
117
- export type RecapSource = 'label' | 'last' | 'prompt';
130
+ export type RecapSource = 'label' | 'generated' | 'prompt';
131
+ /**
132
+ * How urgent a row's secondary line is (PHNX-3797 owner feedback), best-first:
133
+ * - `question` — the agent asked something and is parked on the answer;
134
+ * - `needs_you` — blocked on the operator (a plan review, a permission, or a
135
+ * generic input-required wait) — the "needs you" case;
136
+ * - `activity` — nothing blocking; just what the agent is doing right now.
137
+ *
138
+ * `question`/`needs_you` are the signals a plain rolling activity preview buries,
139
+ * which is why they get their own ranked line distinct from the {@link ActiveSession.title}
140
+ * headline.
141
+ */
142
+ export type ImportantMessageKind = 'question' | 'needs_you' | 'activity';
143
+ /**
144
+ * The single most important recent agent message for a row's secondary line
145
+ * (PHNX-3797 owner feedback). Distinct from the headline ({@link ActiveSession.title}),
146
+ * which says what the session IS: this says what the agent is doing or waiting on
147
+ * RIGHT NOW, ranked by {@link deriveImportantMessage} so a blocking question or a
148
+ * needs-you beats generic activity.
149
+ */
150
+ export interface SessionImportantMessage {
151
+ /** The message to show, trimmed + capped for a single line. */
152
+ text: string;
153
+ /** Its urgency class — drives ordering and any glyph/color the renderer adds. */
154
+ kind: ImportantMessageKind;
155
+ }
118
156
  export interface ActiveSession {
119
157
  context: ActiveContext;
120
158
  kind: string;
@@ -148,12 +186,21 @@ export interface ActiveSession {
148
186
  */
149
187
  lastUserMessage?: string;
150
188
  /**
151
- * The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
152
- * The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
153
- * the last agent line (`lastAgentLine`) → the first-prompt `topic`. A session
154
- * whose agent did work shows an agent-derived line, not the stale first
155
- * prompt. Folded on at the end of {@link getActiveSessions}; `recapSource`
156
- * names which rung produced it.
189
+ * The daemon-generated session title (PHNX-3797) — a short technical label for
190
+ * what the session worked on, produced once per session by the `session-title`
191
+ * service and backfilled from the index by {@link backfillActiveRowsFromMeta}.
192
+ * Rung 2 of the headline ladder; see {@link title}.
193
+ */
194
+ generatedTitle?: string;
195
+ /**
196
+ * The row's shown title — WHAT the session is, best-source-wins (RUSH-3011,
197
+ * reshaped by PHNX-3797). The ladder ({@link deriveSessionRecap}): a `/rename`
198
+ * or harness `label` → the daemon-generated `generatedTitle` → the classified
199
+ * user prompt (from {@link lastUserMessage}/{@link firstUserMessage}/`topic`,
200
+ * PHNX-3939). It is a user-anchored NAME, never the agent's latest turn — that
201
+ * line stays in `lastAgentLine`/`preview`, where a live rolling status belongs.
202
+ * Folded on at the end of {@link getActiveSessions} (and re-derived after an
203
+ * index backfill); `recapSource` names which rung produced it.
157
204
  */
158
205
  title?: string;
159
206
  /** Which ladder rung produced {@link title}. */
@@ -168,11 +215,22 @@ export interface ActiveSession {
168
215
  userPromptKind?: UserPromptKind;
169
216
  /**
170
217
  * The most recent assistant line (from the transcript tail) — the free,
171
- * always-current signal of what the agent last said/did. Ladder rung 3.
218
+ * always-current signal of what the agent last said/did. A LIVE-status field
219
+ * that belongs next to `preview`/`activity`; it is deliberately not a rung of
220
+ * the {@link title} ladder (PHNX-3797).
172
221
  */
173
222
  lastAgentLine?: string;
174
223
  /** Live preview: the latest turn (agent message or tool action), from the state engine. */
175
224
  preview?: string;
225
+ /**
226
+ * The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
227
+ * agent message — a pending question, a needs-you block, or the current activity —
228
+ * ranked by {@link deriveImportantMessage}. Distinct from the {@link title}
229
+ * headline; folded on beside it in {@link foldRecap} and carried on the same
230
+ * `sessions watch --json` / mirror feed (via spread) so AGI EXT can render a bold
231
+ * title over a dim secondary line without a second query.
232
+ */
233
+ importantMessage?: SessionImportantMessage;
176
234
  /** Inferred activity: working / waiting_input / idle (from the transcript tail). */
177
235
  activity?: SessionActivity;
178
236
  /**
@@ -937,7 +995,8 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
937
995
  export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
938
996
  export declare function foldHostLink(rows: ActiveSession[]): void;
939
997
  /**
940
- * Labels win, then the last assistant line, then the prompt.
998
+ * The headline ladder (PHNX-3797): an explicit `/rename` label, then the
999
+ * daemon-generated title, then the classified user prompt.
941
1000
  *
942
1001
  * The prompt rung classifies the RAW latest genuine user turn, not the
943
1002
  * already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
@@ -946,8 +1005,15 @@ export declare function foldHostLink(rows: ActiveSession[]): void;
946
1005
  * that scaffolding as the user's words. It also never saw the row's attachments,
947
1006
  * so an image-only turn could not be recognized. A row that has already been
948
1007
  * merged with a daemon-folded {@link ActiveSession.request} uses it directly.
1008
+ *
1009
+ * The agent's last transcript line is deliberately NOT a rung. It is still
1010
+ * returned as `lastAgentLine` — and still shown, in the separate live
1011
+ * preview/activity slot — but a rolling monologue is not what the session IS,
1012
+ * and using it as the headline is the defect this ladder fixes. A session the
1013
+ * titler has not reached yet falls back to the classified user prompt, never
1014
+ * to the agent's last line.
949
1015
  */
950
- export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
1016
+ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'generatedTitle' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
951
1017
  title?: string;
952
1018
  recapSource?: RecapSource;
953
1019
  userPromptClean?: string;
@@ -955,6 +1021,17 @@ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 't
955
1021
  lastAgentLine?: string;
956
1022
  request?: SessionRequest;
957
1023
  };
1024
+ /**
1025
+ * The row's SECONDARY line (PHNX-3797 owner feedback): the most important recent
1026
+ * agent message, ranked so the operator sees a block before generic chatter —
1027
+ * a pending question, then a needs-you wait (plan review / permission /
1028
+ * input-required), then the current activity (the agent's latest line). Returns
1029
+ * `undefined` only when there is nothing recent to show at all.
1030
+ *
1031
+ * Pure over the fields it reads so the ranking is unit-tested directly; folded
1032
+ * onto every row by {@link applyRecap} beside the {@link deriveSessionRecap} title.
1033
+ */
1034
+ export declare function deriveImportantMessage(row: Pick<ActiveSession, 'status' | 'activity' | 'awaitingReason' | 'question' | 'preview' | 'lastAgentLine'>): SessionImportantMessage | undefined;
958
1035
  /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
959
1036
  export declare function foldRecap(rows: ActiveSession[]): void;
960
1037
  /**