@phnx-labs/agents-cli 1.22.109 → 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 (64) hide show
  1. package/CHANGELOG.md +44 -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/fork.js +6 -4
  7. package/dist/commands/logs.js +2 -1
  8. package/dist/commands/sessions-backfill.d.ts +30 -0
  9. package/dist/commands/sessions-backfill.js +72 -0
  10. package/dist/commands/sessions-inject.d.ts +3 -3
  11. package/dist/commands/sessions-inject.js +5 -11
  12. package/dist/commands/sessions-picker.js +10 -7
  13. package/dist/commands/sessions-resume.d.ts +3 -1
  14. package/dist/commands/sessions-resume.js +32 -3
  15. package/dist/commands/sessions.js +44 -24
  16. package/dist/commands/utils.d.ts +9 -0
  17. package/dist/commands/utils.js +18 -0
  18. package/dist/commands/watchdog.js +4 -1
  19. package/dist/lib/accounting/rotate.d.ts +9 -0
  20. package/dist/lib/accounting/rotate.js +17 -1
  21. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  22. package/dist/lib/accounting/usage-sync.js +70 -6
  23. package/dist/lib/accounting/usage.d.ts +32 -0
  24. package/dist/lib/accounting/usage.js +37 -2
  25. package/dist/lib/auth-health.js +19 -0
  26. package/dist/lib/claude-statusline.js +5 -0
  27. package/dist/lib/computer/sessions-list.js +2 -1
  28. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  29. package/dist/lib/daemon/auth-sync-service.js +2 -2
  30. package/dist/lib/daemon/daemon.js +7 -0
  31. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  32. package/dist/lib/daemon/session-title-service.js +76 -0
  33. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  34. package/dist/lib/daemon/usage-sync-service.js +10 -2
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/daemon-ticks.js +6 -0
  38. package/dist/lib/fleet-shared-state.d.ts +2 -0
  39. package/dist/lib/hooks/install.js +14 -7
  40. package/dist/lib/mailbox-target.js +2 -1
  41. package/dist/lib/session/active.d.ts +93 -16
  42. package/dist/lib/session/active.js +80 -14
  43. package/dist/lib/session/db.d.ts +44 -1
  44. package/dist/lib/session/db.js +122 -17
  45. package/dist/lib/session/fork.d.ts +10 -2
  46. package/dist/lib/session/fork.js +11 -2
  47. package/dist/lib/session/live-metadata.js +6 -0
  48. package/dist/lib/session/mirror.d.ts +3 -2
  49. package/dist/lib/session/mirror.js +5 -2
  50. package/dist/lib/session/remote/watch.js +18 -8
  51. package/dist/lib/session/title.d.ts +256 -0
  52. package/dist/lib/session/title.js +312 -0
  53. package/dist/lib/session/tool-index.d.ts +5 -0
  54. package/dist/lib/session/tool-index.js +1 -0
  55. package/dist/lib/session/types.d.ts +11 -0
  56. package/dist/lib/startup/root-command.d.ts +2 -0
  57. package/dist/lib/startup/root-command.js +22 -0
  58. package/dist/lib/traces/sync.js +1 -0
  59. package/dist/lib/usage-refresh.d.ts +58 -7
  60. package/dist/lib/usage-refresh.js +145 -23
  61. package/dist/lib/watchdog/runner.d.ts +3 -0
  62. package/dist/lib/watchdog/runner.js +1 -0
  63. package/dist/session-tracker/dist/install-hook.js +4 -4
  64. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.110
4
+
5
+ - **Session rows are named by what YOU asked for, not by the agent's latest
6
+ message (PHNX-3797).** Every headline in `agents sessions`, the picker, and the
7
+ AGI EXT Sessions/Fleet list used to be the agent's last transcript line
8
+ ("Both seams verified on the real shipped artifacts…") — verbose, rolling, and
9
+ unrecognizable. The headline ladder is now `/rename` label → a
10
+ daemon-generated title → your own first message; the agent's live line stays in
11
+ the separate preview slot where a rolling status belongs. The new
12
+ `session-title` daemon service generates a short, descriptive **action + object
13
+ headline** ("Triage the AGI board", not just "Triage") with a cheap model
14
+ through a swappable `SessionTitleProvider` (cloud default; a local ollama-style
15
+ backend can be dropped in later without touching the tick), read-only plan mode,
16
+ **once** per session and
17
+ persists it in the session index against a hash of the message it came from, so
18
+ a titled session costs no further model calls and only re-titles when that first
19
+ message changes. Nothing generates per client or per tick: the value rides the
20
+ existing `sessions watch --json` stream and the fleet session mirror, so a
21
+ remote box's rows show the same title with no per-row SSH. Until the titler
22
+ reaches a session it shows your own words — never an agent line.
23
+ `agents sessions backfill titles [--session <id>] [--refresh]` runs it on
24
+ demand; `agents daemon services` can turn the service off.
25
+ - **Session rows carry a ranked secondary line (PHNX-3797).** Beside the bold
26
+ headline, every live row now folds on `importantMessage` — the single most
27
+ important recent agent message, ranked so a pending **question** or a
28
+ **needs-you** block (plan review, permission, input-required) beats generic
29
+ current activity. It rides the same `sessions watch --json` / mirror feed as the
30
+ title (via the row spread), so AGI EXT renders a bold title over a dim secondary
31
+ line with no extra query; `agents sessions --active` prints the question /
32
+ needs-you line dim beneath the row.
33
+ Source: `src/lib/session/title.ts`, `src/lib/daemon/session-title-service.ts`,
34
+ `src/lib/session/active.ts`, `src/lib/session/db.ts`, `src/lib/session/mirror.ts`,
35
+ `src/commands/sessions.ts`, `src/commands/sessions-backfill.ts`.
36
+
37
+ - Codex account sessions record their originating account after hook sync, including macOS homes relocated for Unix socket limits. Registration refreshes trust for the runtime home and removes the obsolete builtin tracker that could overwrite account metadata; unrelated hooks and explicit disabled states are preserved.
38
+
39
+ - **Session resume preserves device and picker filters (PHNX-3940).** `sessions resume --device <host> <id>` and the equivalent device-after-ID form now open on the selected origin device. Agent, account, directory, time, and limit filters reach the shared picker; an unspecified limit remains 200. Repeated different devices produce an error.
40
+
41
+ - **Usage snapshots stay fresh enough for balanced to auto-pick (PHNX-3940 W3).** A headed box polls only the accounts it holds native logins for; a setup-token-only worker never hits the usage endpoint. A changed snapshot is published immediately (statusline ingest or poll) instead of waiting for the 15-minute tick. `isUsageVerified` trusts a row that arrived via sync from the account's poller for that 15-minute cadence, and keeps the 5-minute bar for a local capture. `auth-sync` and `usage-sync` take the shared-repo lock on different offsets so they no longer starve each other. Source: `cli/src/lib/usage-refresh.ts`, `cli/src/lib/accounting/rotate.ts`, `cli/src/lib/accounting/usage-sync.ts`.
42
+
3
43
  ## 1.22.109
4
44
 
5
45
  - **`agents sessions backup-setup` provisions the managed session-backup endpoint (PHNX-3726).** The operator command that deploys `sessions.agents-cli.sh` — the Cloudflare Worker + R2 bucket a signed-in user's `agents sessions export --to-r2` backs up to with no `r2.backups` bucket of their own. Mirrors `agents traces setup`: reads Cloudflare credentials from the `cloudflare` secrets bundle (`agents secrets exec cloudflare -- agents sessions backup-setup`), idempotent (re-running redeploys the current Worker template in place). It is NOT a per-user step — signing in with `agents auth login` is all a user does; this is how the first-party endpoint itself is deployed. The Worker it deploys is Phoenix-only with no static token (PHNX-3726). Source: `cli/src/commands/sessions-backup-setup.ts`, `cli/src/lib/session/sync/provision.ts` (+ `provision.test.ts`), `cli/docs/sessions.md`.
@@ -39,6 +79,10 @@
39
79
 
40
80
  - **`agents view` shows OpenCode's real model, account, plan, and last-active (PHNX-3982).** The OpenCode row was the only one in the table with nothing usable in it — `1.18.15 (default) default id:meta+openai+opencode-go` — while every other harness showed a model, an email, and a plan. Three separate reads were pointed at the wrong place. (1) The model came from `<home>/.opencode/settings.json`, which is agents-cli's own plugin-enablement file; OpenCode reads `~/.config/opencode/opencode.{jsonc,json}`, and ships no catalog default, so the row fell through to the literal placeholder `default`. Both spellings are now read comment-tolerantly, and when nothing is configured the row shows the model OpenCode itself will start with, from the selection it persists to `$XDG_STATE_HOME/opencode/model.json`. (2) The account showed only the provider join because the code asserted OpenCode's `auth.json` carries no identity — it does, for `type: 'oauth'` providers, in the same namespaced JWT claims `case 'codex'` already decodes, so the email and plan now render (secrets are never read out, only claims). (3) `lastActive` was blank because the per-file session walk finds nothing for a harness that keeps every session in one sqlite file; it now dates from `opencode.db`. Live usage *windows* remain unavailable for OpenCode — it records no rate-limit state locally and each provider would need its own reader. Source: `cli/src/lib/agent-spec/agents.ts`, `cli/src/lib/models.ts`.
41
81
 
82
+ - **Codex trace error rates now reflect failed tool calls (PHNX-3761).** The tool-call index recognizes Codex's script completion and failure markers, preserves text across input blocks, and re-indexes older rows instead of reporting failed calls as unknown.
83
+
84
+ - **`agents sessions --cloud <id>` and `agents cloud view` open OpenCode cloud transcripts (PHNX-3845).** Cloud discovery and fetch accept OpenCode sessions, and normalized cloud transcripts are parsed separately from local SQLite sessions. Both `bash` and `shell` tool names appear as command events.
85
+
42
86
  ## 1.22.108
43
87
 
44
88
  - Account sync now refreshes the native session-tracking hook in every account home, replacing stale registrations from previous CLI installations so new sessions retain their account identity. Codex tracker installation also refreshes hook trust so headless runs can execute the registered hook.
package/dist/bootstrap.js CHANGED
@@ -19,7 +19,7 @@ import * as fs from 'fs';
19
19
  import * as path from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
  import { detectDevBuild } from './lib/startup/dev-build.js';
22
- import { configureRootCommand } from './lib/startup/root-command.js';
22
+ import { configureRootCommand, normalizeResumeDeviceArgs } from './lib/startup/root-command.js';
23
23
  import { bootMark } from './lib/boot-profile.js';
24
24
  // `ora`, `@inquirer/prompts`, `./commands/utils.js`, and the agents/versions/shims
25
25
  // modules are imported dynamically at their use sites: they are needed only on
@@ -877,7 +877,8 @@ program.on('command:*', (operands) => {
877
877
  // Parse the invocation shape up front: the first non-flag token is the command,
878
878
  // and the doc flags (--version/--help/-h) drive both the registration strategy
879
879
  // and whether the update check + background sync run at all.
880
- const passedArgs = process.argv.slice(2);
880
+ const passedArgs = normalizeResumeDeviceArgs(process.argv.slice(2));
881
+ process.argv.splice(2, process.argv.length - 2, ...passedArgs);
881
882
  // Commander owns `--version` on the root command and otherwise intercepts it
882
883
  // even after `sessions`, before the subcommand can parse its version filter.
883
884
  // Rewrite only that value-taking nested form; bare `agents --version` and every
@@ -18,6 +18,7 @@ import { buildPreview } from './sessions-picker.js';
18
18
  import { createSessionsPickerCommand } from './sessions-picker-factory.js';
19
19
  import { runBrowserSessions, buildBrowserSessionRows, matchesBrowserSessionRow, formatBytes, } from '../lib/browser/sessions-list.js';
20
20
  import { formatRelativeTime } from '../lib/session/relative-time.js';
21
+ import { sessionHeadline } from '../lib/session/title.js';
21
22
  const KIND_LABEL = {
22
23
  screenshot: 'shots',
23
24
  pdf: 'pdf',
@@ -37,7 +38,7 @@ function rowLinkSummary(row) {
37
38
  return '';
38
39
  if (row.linkStatus === 'linked' && row.linkedSession) {
39
40
  const s = row.linkedSession;
40
- return chalk.cyan(s.agent) + ' — ' + (s.label || s.topic || s.shortId);
41
+ return chalk.cyan(s.agent) + ' — ' + (sessionHeadline(s) || s.shortId);
41
42
  }
42
43
  if (row.linkStatus === 'unresolved') {
43
44
  return chalk.yellow(`owner ${row.owner ?? 'unknown'} (session not indexed here)`);
@@ -21,10 +21,11 @@ import { buildPreview } from './sessions-picker.js';
21
21
  import { createSessionsPickerCommand } from './sessions-picker-factory.js';
22
22
  import { runComputerSessions, printComputerSessionRows, buildComputerSessionRows, matchesComputerSessionRow, formatRowActions, } from '../lib/computer/sessions-list.js';
23
23
  import { formatRelativeTime } from '../lib/session/relative-time.js';
24
+ import { sessionHeadline } from '../lib/session/title.js';
24
25
  function rowLinkSummary(row) {
25
26
  if (row.linkStatus === 'linked' && row.linkedSession) {
26
27
  const s = row.linkedSession;
27
- return chalk.cyan(s.agent) + ' — ' + (s.label || s.topic || s.shortId);
28
+ return chalk.cyan(s.agent) + ' — ' + (sessionHeadline(s) || s.shortId);
28
29
  }
29
30
  if (row.linkStatus === 'unresolved') {
30
31
  return chalk.yellow(`owner ${row.agent ?? 'unknown'} (session not indexed here)`);
@@ -5,6 +5,7 @@ import { queryUsageRollup, topSessionsByCost, } from '../lib/session/db.js';
5
5
  import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
6
6
  import { formatDuration } from '../lib/session/render.js';
7
7
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
8
+ import { sessionHeadline } from '../lib/session/title.js';
8
9
  /** Register `agents insights cost` under the insights parent. */
9
10
  export function registerCostCommand(insightsCmd) {
10
11
  addHostOption(insightsCmd.command('cost'))
@@ -99,7 +100,7 @@ async function costAction(options) {
99
100
  for (const t of top) {
100
101
  const cost = formatUsd(t.costUsd).padStart(costW);
101
102
  const dur = t.durationMs > 0 ? formatDuration(t.durationMs) : '—';
102
- const label = (t.meta.label || t.meta.topic || '(untitled)').replace(/\s+/g, ' ').trim();
103
+ const label = (sessionHeadline(t.meta) || '(untitled)').replace(/\s+/g, ' ').trim();
103
104
  const proj = showProject && t.meta.project ? chalk.gray(` ${t.meta.project}`) : '';
104
105
  const prefix = ` ${chalk.green(cost)} ${chalk.gray(t.meta.shortId)} ${chalk.cyan(t.meta.agent.padEnd(7))} `;
105
106
  const suffix = proj + chalk.gray(` ${dur}`);
@@ -97,10 +97,12 @@ export async function runFork(sessionArg, options, deps = defaultDeps()) {
97
97
  return;
98
98
  }
99
99
  const digest = data?.preview ?? undefined;
100
- // Most sessions have no explicit --name label; fall back to the auto-derived
101
- // topic the rest of the CLI shows, not the raw short id (forkLabelFor is the
102
- // shared 3-tier resolver, and preview's --json now carries `topic`).
103
- const label = forkLabelFor({ label: source.label, topic: source.topic, shortId: source.shortId });
100
+ // Most sessions have no explicit --name label; fall back to the shared headline
101
+ // ladder the rest of the CLI shows, not the raw short id. Pass `source` whole
102
+ // rather than re-listing fields: an object literal that omits `generatedTitle`
103
+ // still type-checks (the key is optional) and would silently drop the
104
+ // daemon-generated title rung — the PHNX-3797 bug shape.
105
+ const label = forkLabelFor(source);
104
106
  const recap = buildForkRecap({
105
107
  agent: source.agent,
106
108
  label,
@@ -29,6 +29,7 @@ import { listTasks } from '../lib/hosts/tasks.js';
29
29
  import { itemPicker } from '../lib/picker.js';
30
30
  import { stats, getLogsPath, rotate, } from '../lib/feed/events.js';
31
31
  import { addEventsReadOptions, runEventsCommand } from './events.js';
32
+ import { sessionHeadline } from '../lib/session/title.js';
32
33
  /** Compact one-line label used by both the picker and the non-TTY list. */
33
34
  function candidateLabel(c) {
34
35
  if (c.kind === 'task') {
@@ -40,7 +41,7 @@ function candidateLabel(c) {
40
41
  }
41
42
  const s = c.session;
42
43
  const ver = s.version ? chalk.gray(`@${s.version}`) : '';
43
- const title = s.label || s.topic || '';
44
+ const title = sessionHeadline(s) || '';
44
45
  return `${chalk.gray('sess')} ${s.shortId.padEnd(9)} ${(s.agent + ver).padEnd(14)} ${chalk.gray(s.timestamp.slice(0, 16))} ${title.slice(0, 40)}`;
45
46
  }
46
47
  /** Emit a host-dispatch task's log as JSON: `{ kind, task, log }`. */
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type ToolIndexCoverage } from '../lib/session/tool-index.js';
3
+ import { type SessionTitleRunner } from '../lib/session/title.js';
3
4
  export interface ToolBackfillMachineResult {
4
5
  machine: string;
5
6
  indexedFiles: number;
@@ -61,5 +62,34 @@ export interface ResourceBackfillEnvelope {
61
62
  * resource_scan_ledger so reruns skip completed transcripts.
62
63
  */
63
64
  export declare function runResourceBackfill(options: ResourceBackfillOptions): Promise<ResourceBackfillEnvelope>;
65
+ interface TitleBackfillOptions {
66
+ session?: string;
67
+ limit?: string;
68
+ refresh?: boolean;
69
+ json?: boolean;
70
+ /** The injectable model call (`SessionTitleRunner`); not a CLI flag — tests pass it. */
71
+ run?: SessionTitleRunner;
72
+ }
73
+ export interface TitleBackfillEnvelope {
74
+ schemaVersion: 1;
75
+ kind: 'titles-backfill';
76
+ generatedAt: string;
77
+ machine: string;
78
+ scanned: number;
79
+ cached: number;
80
+ generated: number;
81
+ failed: number;
82
+ titles: Array<{
83
+ id: string;
84
+ title: string;
85
+ }>;
86
+ }
87
+ /**
88
+ * Generate session headlines NOW instead of waiting for the daemon's sweep — the
89
+ * explicit-refresh half of PHNX-3797. Same code path the `session-title` service
90
+ * ticks, so there is one generator: this only changes when it runs, how many it
91
+ * does, and (with `--refresh`) whether an already-current title is regenerated.
92
+ */
93
+ export declare function runTitlesBackfill(options: TitleBackfillOptions): Promise<TitleBackfillEnvelope>;
64
94
  export declare function registerSessionsBackfillCommand(sessionsCmd: Command): void;
65
95
  export {};
@@ -7,6 +7,7 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
7
7
  import { ensureToolIndex, readToolIndexCoverage } from '../lib/session/tool-index.js';
8
8
  import { NO_FANOUT_ENV } from '../lib/session/remote-active.js';
9
9
  import { backfillResourceUsage } from '../lib/session/db.js';
10
+ import { runSessionTitleTick, SESSION_TITLE_MAX_PER_TICK } from '../lib/session/title.js';
10
11
  const BACKFILL_REMOTE_TIMEOUT_MS = 10 * 60_000;
11
12
  function parseAgent(value) {
12
13
  if (!value)
@@ -181,6 +182,31 @@ export async function runResourceBackfill(options) {
181
182
  ...result,
182
183
  };
183
184
  }
185
+ /**
186
+ * Generate session headlines NOW instead of waiting for the daemon's sweep — the
187
+ * explicit-refresh half of PHNX-3797. Same code path the `session-title` service
188
+ * ticks, so there is one generator: this only changes when it runs, how many it
189
+ * does, and (with `--refresh`) whether an already-current title is regenerated.
190
+ */
191
+ export async function runTitlesBackfill(options) {
192
+ const parsedLimit = options.limit ? Number.parseInt(options.limit, 10) : undefined;
193
+ if (options.limit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 1)) {
194
+ throw new Error(`--limit must be a positive integer (got "${options.limit}")`);
195
+ }
196
+ const result = await runSessionTitleTick({
197
+ ...(options.session ? { id: options.session } : {}),
198
+ ...(options.run ? { run: options.run } : {}),
199
+ limit: parsedLimit ?? (options.session ? 1 : SESSION_TITLE_MAX_PER_TICK * 5),
200
+ force: Boolean(options.refresh),
201
+ });
202
+ return {
203
+ schemaVersion: 1,
204
+ kind: 'titles-backfill',
205
+ generatedAt: new Date().toISOString(),
206
+ machine: machineId(),
207
+ ...result,
208
+ };
209
+ }
184
210
  export function registerSessionsBackfillCommand(sessionsCmd) {
185
211
  const backfill = sessionsCmd.command('backfill').description('Populate derived session data explicitly.');
186
212
  const tools = backfill.command('tools').description('Parse historical tool calls once into the local SQLite index.');
@@ -265,4 +291,50 @@ export function registerSessionsBackfillCommand(sessionsCmd) {
265
291
  process.exitCode = 1;
266
292
  }
267
293
  });
294
+ const titles = backfill
295
+ .command('titles')
296
+ .description('Generate the session-row headline (a short technical title) now, instead of waiting for the daemon.')
297
+ .option('--session <id>', 'Title one session (full or short id), even if it is old or already titled')
298
+ .option('--limit <n>', 'Maximum titles to generate in this run')
299
+ .option('--refresh', 'Regenerate even when the stored title still matches the session\'s first user message')
300
+ .option('--json', 'Emit the machine-readable result');
301
+ setHelpSections(titles, {
302
+ examples: `
303
+ # Catch this machine up now (the daemon otherwise does a couple every 2 min)
304
+ agents sessions backfill titles
305
+
306
+ # Re-title one session after correcting its first message
307
+ agents sessions backfill titles --session 6fc1db18 --refresh
308
+
309
+ # Machine-readable
310
+ agents sessions backfill titles --limit 20 --json
311
+ `,
312
+ notes: `
313
+ - The headline ladder is: \`/rename\` label > this generated title > the user's first message. It is never the agent's latest turn.
314
+ - One cheap-model call per session (\`--model cheap\`, read-only plan mode), generated ONCE and persisted; a session whose first message has not changed is a pure cache hit.
315
+ - Local-only: each box titles its own sessions and publishes them to the fleet via the session mirror, so a peer's rows already carry their titles.
316
+ - Best-effort. With no signed-in harness nothing is generated and rows keep showing the user's own words.
317
+ `,
318
+ });
319
+ titles.action(async (_options, command) => {
320
+ const options = command.optsWithGlobals();
321
+ try {
322
+ const envelope = await runTitlesBackfill(options);
323
+ if (options.json)
324
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
325
+ else {
326
+ console.log(`${envelope.machine}: generated ${envelope.generated.toLocaleString()} title${envelope.generated === 1 ? '' : 's'}; ` +
327
+ `${envelope.cached.toLocaleString()} already current, ${envelope.scanned.toLocaleString()} scanned.`);
328
+ for (const { id, title } of envelope.titles)
329
+ console.log(` ${chalk.cyan(id.slice(0, 8))} ${title}`);
330
+ if (envelope.failed > 0) {
331
+ console.log(chalk.yellow(`${envelope.failed.toLocaleString()} session${envelope.failed === 1 ? '' : 's'} produced no usable title; rerun to retry.`));
332
+ }
333
+ }
334
+ }
335
+ catch (error) {
336
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
337
+ process.exitCode = 1;
338
+ }
339
+ });
268
340
  }
@@ -43,9 +43,9 @@ export declare function matchInjectSelector(session: ActiveSession, token: strin
43
43
  * merges the parent `sessions` command's variadic `-D, --device <target...>` over
44
44
  * this subcommand's scalar `--device`, so a single `--device box` arrives as
45
45
  * `['box']` — which flowed straight into `sshExec` and crashed on
46
- * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
47
- * several, since inject delivers to exactly one terminal (a fan-out spelling is a
48
- * user error, not a first-of-list guess).
46
+ * `host.startsWith` (PHNX-3688). Delegates to the shared
47
+ * {@link normalizeSingleDeviceOption} (also used by `sessions resume`,
48
+ * PHNX-3940) rather than re-implementing the array/scalar coercion here.
49
49
  */
50
50
  export declare function normalizeInjectDevice(value: string | string[] | undefined): string | undefined;
51
51
  /**
@@ -19,6 +19,7 @@ import { sshExec, shellQuote } from '../lib/ssh-exec.js';
19
19
  import { resolveHost } from '../lib/hosts/registry.js';
20
20
  import { sshTargetFor } from '../lib/hosts/types.js';
21
21
  import { setHelpSections } from '../lib/help.js';
22
+ import { normalizeSingleDeviceOption } from './utils.js';
22
23
  /**
23
24
  * Whether an active session is the one `sessions inject <token>` means. Matches
24
25
  * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
@@ -47,19 +48,12 @@ export function matchInjectSelector(session, token) {
47
48
  * merges the parent `sessions` command's variadic `-D, --device <target...>` over
48
49
  * this subcommand's scalar `--device`, so a single `--device box` arrives as
49
50
  * `['box']` — which flowed straight into `sshExec` and crashed on
50
- * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
51
- * several, since inject delivers to exactly one terminal (a fan-out spelling is a
52
- * user error, not a first-of-list guess).
51
+ * `host.startsWith` (PHNX-3688). Delegates to the shared
52
+ * {@link normalizeSingleDeviceOption} (also used by `sessions resume`,
53
+ * PHNX-3940) rather than re-implementing the array/scalar coercion here.
53
54
  */
54
55
  export function normalizeInjectDevice(value) {
55
- const list = value == null ? [] : Array.isArray(value) ? value : [value];
56
- const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
57
- if (hosts.length === 0)
58
- return undefined;
59
- if (hosts.length > 1) {
60
- throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
61
- }
62
- return hosts[0];
56
+ return normalizeSingleDeviceOption(value, 'sessions inject');
63
57
  }
64
58
  /**
65
59
  * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
@@ -106,6 +106,7 @@ function sanitizeMeta(s) {
106
106
  topic: clean(s.topic),
107
107
  firstUserMessage: clean(s.firstUserMessage),
108
108
  label: clean(s.label),
109
+ generatedTitle: clean(s.generatedTitle),
109
110
  ticketId: clean(s.ticketId),
110
111
  prUrl: clean(s.prUrl),
111
112
  // A remote row's meta is peer-supplied JSON that parseRemoteList hands over
@@ -303,6 +304,7 @@ function previewCacheKey(session, remote) {
303
304
  // independently (label/ticket/PR/live scanner enrichment) also rides the key.
304
305
  return JSON.stringify([
305
306
  remote ?? '', remoteDigestState, session.id, fileStamp, session.lastActivity, session.label,
307
+ session.generatedTitle,
306
308
  session.topic, session.ticketId, session.prUrl, session.messageCount,
307
309
  session.tokenCount, session.model, session.todos, session.plan,
308
310
  session.recentDirectoriesTouched, session.skillsUsed,
@@ -489,13 +491,14 @@ function formatHeader(session, events) {
489
491
  line4.push(chalk.blue(linkUrl(session.prUrl, label)));
490
492
  }
491
493
  // Lead with the session's human title: `session.label` — an agent-generated
492
- // name / `/rename`, else the `--name` launch handle. NOT `session.topic`: the
493
- // topic is the derived first-prompt, already shown on the `Prompt:` line, so
494
- // using it here too would print the same text twice. Unlabelled sessions keep
495
- // that `Prompt:` line as their topic indicator and simply lead with the agent
496
- // line. Wrapped to the pane (the header sits at column 0, full terminal width);
497
- // nothing renders when there is no label.
498
- const title = (session.label || '').trim();
494
+ // name / `/rename`, else the `--name` launch handle — else the daemon-generated
495
+ // title (PHNX-3797), which is a short technical NAME, not a restatement of the
496
+ // prompt. NOT `session.topic`: the topic is the derived first-prompt, already
497
+ // shown on the `Prompt:` line, so using it here too would print the same text
498
+ // twice. A session with neither keeps that `Prompt:` line as its topic
499
+ // indicator and simply leads with the agent line. Wrapped to the pane (the
500
+ // header sits at column 0, full terminal width).
501
+ const title = (session.label || session.generatedTitle || '').trim();
499
502
  const titleLines = title
500
503
  ? wrapToWidth(title, terminalWidth()).map(l => chalk.bold.white(l))
501
504
  : [];
@@ -1,4 +1,4 @@
1
- import type { Command } from 'commander';
1
+ import { type Command } from 'commander';
2
2
  import { type SessionMeta } from '../lib/session/types.js';
3
3
  import { type Backend, type SurfaceItem, type EngineContext, type Packing } from '../lib/terminal/index.js';
4
4
  import { type StrictResumeOptions } from './resume.js';
@@ -24,6 +24,8 @@ export interface ResumeOptions extends StrictResumeOptions {
24
24
  runArgs?: string[];
25
25
  }
26
26
  export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
27
+ /** Keep explicit parent options without replacing resume's own defaults. */
28
+ export declare function resolveResumeOptions(cmd: Command, local: ResumeOptions): ResumeOptions;
27
29
  export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
28
30
  /** Preserve run options and lifecycle intent when the picker opens its selected rows. */
29
31
  export declare function buildSelectedResumeArgs(id: string, prompt: string | undefined, options: ResumeOptions): string[];
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import * as fs from 'fs';
14
14
  import chalk from 'chalk';
15
+ import { Option } from 'commander';
15
16
  import { isAgentTmuxAlias } from '../lib/session/types.js';
16
17
  import { discoverSessions } from '../lib/session/discover.js';
17
18
  import { filterTeamSessions } from '../lib/session/team-filter.js';
@@ -20,7 +21,7 @@ import { buildPreview } from './sessions-picker.js';
20
21
  import { formatPickerLabel, pickerColumnsFor, resolveSessionMetadataValue, parseAgentFilter, } from './sessions.js';
21
22
  import { sessionMatchesQuery } from './sessions-browser.js';
22
23
  import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, shellQuote, } from '../lib/terminal/index.js';
23
- import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
24
+ import { isInteractiveTerminal, isPromptCancelled, normalizeSingleDeviceOption } from './utils.js';
24
25
  import { setHelpSections } from '../lib/help.js';
25
26
  import { confirm } from '@inquirer/prompts';
26
27
  import { spawn } from 'node:child_process';
@@ -30,6 +31,7 @@ import { sessionOriginDevice, sessionRecoveryDestinationMatches, sessionRecovery
30
31
  import { buildResumeRemoteArgs, runStrictResume, wantsStrictResume } from './resume.js';
31
32
  import { toRemotePortable } from '../lib/project-root.js';
32
33
  import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
34
+ import { sessionHeadline } from '../lib/session/title.js';
33
35
  /** Opening more than this many live sessions at once asks for confirmation first. */
34
36
  export const CONFIRM_THRESHOLD = 5;
35
37
  export function registerSessionsResumeCommand(sessionsCmd) {
@@ -45,6 +47,8 @@ export function registerSessionsResumeCommand(sessionsCmd) {
45
47
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
46
48
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
47
49
  .option('-n, --limit <n>', 'Maximum number of sessions to load into the picker', '200')
50
+ .addOption(new Option('--resume-device <alias>').hideHelp()
51
+ .argParser((value, previous = []) => [...previous, value]))
48
52
  .option('--device <alias>', 'Open on the session origin device over SSH; the device must match every selected session')
49
53
  .option('--iterm', 'Force the iTerm backend')
50
54
  .option('--ghostty', 'Force the Ghostty backend')
@@ -104,9 +108,34 @@ export function registerSessionsResumeCommand(sessionsCmd) {
104
108
  `,
105
109
  });
106
110
  cmd.action(async (query, prompt, options) => {
107
- await sessionsResumeAction(query, prompt, options);
111
+ await sessionsResumeAction(query, prompt, resolveResumeOptions(cmd, options));
108
112
  });
109
113
  }
114
+ /** Flag names `sessions resume` declares that also exist on its parent `sessions`
115
+ * command. Booleans and strings only — `device` collides too but needs its own
116
+ * array-merge handling below, so it is not in this list. */
117
+ const RESUME_PARENT_COLLISION_FLAGS = ['agent', 'all', 'teams', 'since', 'limit', 'local'];
118
+ /** Keep explicit parent options without replacing resume's own defaults. */
119
+ export function resolveResumeOptions(cmd, local) {
120
+ const parent = cmd.parent;
121
+ const { resumeDevice, ...resolved } = local;
122
+ const parentOpts = parent?.opts();
123
+ for (const key of RESUME_PARENT_COLLISION_FLAGS) {
124
+ if (parent?.getOptionValueSource(key) === 'cli') {
125
+ resolved[key] = parentOpts?.[key];
126
+ }
127
+ }
128
+ const rawDeviceTargets = [
129
+ ...(resumeDevice ?? []),
130
+ ...(local.device ? [local.device] : []),
131
+ ...(parentOpts?.device ?? []),
132
+ ...(parentOpts?.devices ?? []),
133
+ ];
134
+ if (rawDeviceTargets.length > 0) {
135
+ resolved.device = normalizeSingleDeviceOption(rawDeviceTargets, 'sessions resume');
136
+ }
137
+ return resolved;
138
+ }
110
139
  export async function sessionsResumeAction(query, prompt, options) {
111
140
  if (options.attachOnly && (prompt !== undefined || options.agent || options.account || options.model || options.mode || options.interactive || options.headless || options.cwd || options.here)) {
112
141
  throw new Error('--attach-only cannot be combined with --agent, a follow-up prompt, or options that change the running session. Pass the session ID without filters.');
@@ -285,7 +314,7 @@ export async function sessionsResumeAction(query, prompt, options) {
285
314
  command: it.command,
286
315
  agent: it.session.agent || undefined,
287
316
  sessionId: it.session.id || undefined,
288
- title: it.session.label || it.session.topic || undefined,
317
+ title: sessionHeadline(it.session),
289
318
  })), { backend, host: options.device, packing });
290
319
  let opened = 0;
291
320
  results.forEach((r, i) => {
@@ -37,6 +37,7 @@ import { inferSessionState } from '../lib/session/state.js';
37
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
38
38
  import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
39
39
  import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
40
+ import { sessionHeadline } from '../lib/session/title.js';
40
41
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
41
42
  import { parseSession } from '../lib/session/parse.js';
42
43
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -351,6 +352,7 @@ export function buildSessionDescription(s) {
351
352
  return cleanPreview(parts.filter(Boolean).join(' · '));
352
353
  }
353
354
  // Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
355
+ // ladder-exempt: the compact --active preview BASE (a live snippet), not the row's headline title.
354
356
  const base = s.preview || s.label || s.topic || '';
355
357
  return cleanPreview([todo, base].filter(Boolean).join(' · '));
356
358
  }
@@ -759,22 +761,39 @@ export function renderActiveRowLines(s, indent, termW) {
759
761
  // Line 2: label/topic + checklist (the identity, no longer buried) then the
760
762
  // jump locator. Skipped entirely when there is nothing to say. desc may carry a
761
763
  // clickable project link, so it also goes through fitCell.
764
+ const contIndent = indent + ' '.repeat(ROW_ID_W);
762
765
  const desc = formatActiveRowDescription(s);
763
766
  const loc = locatorBadge(s);
764
- if (!desc && !loc)
765
- return lines;
766
- const contIndent = indent + ' '.repeat(ROW_ID_W);
767
- const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
768
- const locCell = fitCell(loc, room2);
769
- const locW = stringWidth(locCell);
770
- const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
771
- const descCell = chalk.white(fitCell(desc || '-', descRoom));
772
- let line2 = contIndent + chalk.dim('└ ') + descCell;
773
- if (locCell)
774
- line2 += ' ' + locCell;
775
- if (stringWidth(line2) > termW)
776
- line2 = truncateToWidth(line2, termW);
777
- lines.push(line2);
767
+ if (desc || loc) {
768
+ const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
769
+ const locCell = fitCell(loc, room2);
770
+ const locW = stringWidth(locCell);
771
+ const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
772
+ const descCell = chalk.white(fitCell(desc || '-', descRoom));
773
+ let line2 = contIndent + chalk.dim('└ ') + descCell;
774
+ if (locCell)
775
+ line2 += ' ' + locCell;
776
+ if (stringWidth(line2) > termW)
777
+ line2 = truncateToWidth(line2, termW);
778
+ lines.push(line2);
779
+ }
780
+ // Secondary line (PHNX-3797 owner feedback): surface the most important recent
781
+ // agent message when it is a pending QUESTION or a NEEDS-YOU block — the urgency
782
+ // a rolling activity preview buries. Plain `activity` is already the preview on
783
+ // line 2, so it earns no extra line here; the full ranked message still rides
784
+ // the JSON/mirror feed for AGI EXT via `s.importantMessage`.
785
+ const important = s.importantMessage;
786
+ if (important && (important.kind === 'question' || important.kind === 'needs_you')) {
787
+ const glyph = important.kind === 'question' ? '? ' : '! ';
788
+ const room3 = Math.max(0, termW - stringWidth(contIndent) - 2 - glyph.length);
789
+ const msgCell = fitCell(cleanPreview(important.text), room3);
790
+ if (msgCell) {
791
+ let line3 = contIndent + chalk.dim(glyph + msgCell);
792
+ if (stringWidth(line3) > termW)
793
+ line3 = truncateToWidth(line3, termW);
794
+ lines.push(line3);
795
+ }
796
+ }
778
797
  return lines;
779
798
  }
780
799
  /** Render a single agent-session row inside an already-printed group header. */
@@ -2200,7 +2219,7 @@ export function printToolSearch(envelope) {
2200
2219
  ? truncate(sanitizeForTerminal(session.machine).replace(/\s+/g, ' '), 80)
2201
2220
  : '';
2202
2221
  const machine = machineName ? ` @ ${machineName}` : '';
2203
- const rawHeading = session.label || session.topic || session.project || session.shortId;
2222
+ const rawHeading = sessionHeadline(session) || session.project || session.shortId;
2204
2223
  const heading = truncate(sanitizeForTerminal(rawHeading).replace(/\s+/g, ' '), Math.max(30, terminalWidth() - 20));
2205
2224
  console.log(`${chalk.cyan(session.shortId)}${chalk.gray(machine)} ${heading}`);
2206
2225
  for (const call of session.calls) {
@@ -3374,7 +3393,7 @@ function printTeamsView(pool, liveIndex, hiddenUnmanaged = 0) {
3374
3393
  // degradation the --active teams rows use (active.ts resolveOrchestratorLabels).
3375
3394
  const labelById = new Map();
3376
3395
  for (const s of pool) {
3377
- const label = s.label || s.topic;
3396
+ const label = sessionHeadline(s);
3378
3397
  if (label)
3379
3398
  labelById.set(s.id, cleanPreview(label));
3380
3399
  }
@@ -3504,7 +3523,7 @@ function renderArchivedSession(session, mode, options = {}) {
3504
3523
  const shown = sessionDisplayAgent(session);
3505
3524
  const agentColor = colorAgent(shown);
3506
3525
  const absTime = formatAbsoluteTime(session.timestamp);
3507
- const title = session.label || session.topic;
3526
+ const title = sessionHeadline(session);
3508
3527
  console.log('');
3509
3528
  if (title)
3510
3529
  console.log(chalk.bold.white(title));
@@ -3562,9 +3581,10 @@ async function renderSession(session, mode, filters, options = {}) {
3562
3581
  const modelStr = stats.models.length > 0 ? chalk.yellow(` ${stats.models.join(', ')}`) : '';
3563
3582
  const branchStr = session.gitBranch ? chalk.gray(` (${session.gitBranch})`) : '';
3564
3583
  const absTime = formatAbsoluteTime(session.timestamp);
3565
- // Auto-inferred title headline (user /rename > Claude ai-title > first-prompt
3566
- // topic) — the fastest way to recognize which task this session is.
3567
- const title = session.label || session.topic;
3584
+ // Auto-inferred title headline (user /rename > Claude ai-title >
3585
+ // daemon-generated title > first-prompt topic) — the fastest way to
3586
+ // recognize which task this session is.
3587
+ const title = sessionHeadline(session);
3568
3588
  if (title) {
3569
3589
  const badges = signalBadges(metaSignals(session));
3570
3590
  console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
@@ -4456,7 +4476,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
4456
4476
  spinner.stop();
4457
4477
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4458
4478
  for (const m of queryMatches.slice(0, 10)) {
4459
- console.error(chalk.cyan(` ${m.shortId} ${m.id} ${m.label ?? m.topic ?? ''}`));
4479
+ console.error(chalk.cyan(` ${m.shortId} ${m.id} ${sessionHeadline(m) ?? ''}`));
4460
4480
  }
4461
4481
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4462
4482
  process.exit(1);
@@ -4613,7 +4633,7 @@ async function renderOneSession(query, mode, scope) {
4613
4633
  spinner.stop();
4614
4634
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4615
4635
  for (const match of queryMatches.slice(0, 10)) {
4616
- console.error(chalk.cyan(` ${match.shortId} ${match.id} ${match.label ?? match.topic ?? ''}`));
4636
+ console.error(chalk.cyan(` ${match.shortId} ${match.id} ${sessionHeadline(match) ?? ''}`));
4617
4637
  }
4618
4638
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4619
4639
  process.exit(1);
@@ -5067,7 +5087,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
5067
5087
  for (const candidate of outcome.candidates) {
5068
5088
  const session = candidate.hits[0].session;
5069
5089
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5070
- console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${session.label ?? session.topic ?? ''}`));
5090
+ console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${sessionHeadline(session) ?? ''}`));
5071
5091
  }
5072
5092
  console.error(chalk.gray(looksLikeSessionId(selector) ? 'Pass a longer ID to narrow it down.' : 'Narrow the keywords to one session.'));
5073
5093
  process.exit(1);
@@ -5107,7 +5127,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
5107
5127
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
5108
5128
  for (const candidate of candidates) {
5109
5129
  const s = candidate.hits[0].session;
5110
- const label = s.label ?? s.topic ?? '';
5130
+ const label = sessionHeadline(s) ?? '';
5111
5131
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5112
5132
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
5113
5133
  }