@phnx-labs/agents-cli 1.22.109 → 1.22.111

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 (66) hide show
  1. package/CHANGELOG.md +48 -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/remote-agents-json.js +1 -1
  42. package/dist/lib/session/active.d.ts +93 -16
  43. package/dist/lib/session/active.js +80 -14
  44. package/dist/lib/session/db.d.ts +44 -1
  45. package/dist/lib/session/db.js +122 -17
  46. package/dist/lib/session/fork.d.ts +10 -2
  47. package/dist/lib/session/fork.js +11 -2
  48. package/dist/lib/session/live-metadata.js +6 -0
  49. package/dist/lib/session/mirror.d.ts +3 -2
  50. package/dist/lib/session/mirror.js +5 -2
  51. package/dist/lib/session/remote/remote-list.js +1 -1
  52. package/dist/lib/session/remote/watch.js +18 -8
  53. package/dist/lib/session/title.d.ts +256 -0
  54. package/dist/lib/session/title.js +312 -0
  55. package/dist/lib/session/tool-index.d.ts +5 -0
  56. package/dist/lib/session/tool-index.js +1 -0
  57. package/dist/lib/session/types.d.ts +11 -0
  58. package/dist/lib/startup/root-command.d.ts +2 -0
  59. package/dist/lib/startup/root-command.js +22 -0
  60. package/dist/lib/traces/sync.js +1 -0
  61. package/dist/lib/usage-refresh.d.ts +58 -7
  62. package/dist/lib/usage-refresh.js +145 -23
  63. package/dist/lib/watchdog/runner.d.ts +3 -0
  64. package/dist/lib/watchdog/runner.js +1 -0
  65. package/dist/session-tracker/dist/install-hook.js +4 -4
  66. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.111
4
+
5
+ - **Remote session resume accepts the resolver’s launch metadata (PHNX-3940).** A reachable peer’s valid session row could be rejected because it included a mode or custom harness, leaving the session undiscoverable. The reader now accepts both fields while keeping private transcript paths out of the resolver contract. Malformed responses are reported as invalid data instead of an unreachable device.
6
+
7
+ ## 1.22.110
8
+
9
+ - **Session rows are named by what YOU asked for, not by the agent's latest
10
+ message (PHNX-3797).** Every headline in `agents sessions`, the picker, and the
11
+ AGI EXT Sessions/Fleet list used to be the agent's last transcript line
12
+ ("Both seams verified on the real shipped artifacts…") — verbose, rolling, and
13
+ unrecognizable. The headline ladder is now `/rename` label → a
14
+ daemon-generated title → your own first message; the agent's live line stays in
15
+ the separate preview slot where a rolling status belongs. The new
16
+ `session-title` daemon service generates a short, descriptive **action + object
17
+ headline** ("Triage the AGI board", not just "Triage") with a cheap model
18
+ through a swappable `SessionTitleProvider` (cloud default; a local ollama-style
19
+ backend can be dropped in later without touching the tick), read-only plan mode,
20
+ **once** per session and
21
+ persists it in the session index against a hash of the message it came from, so
22
+ a titled session costs no further model calls and only re-titles when that first
23
+ message changes. Nothing generates per client or per tick: the value rides the
24
+ existing `sessions watch --json` stream and the fleet session mirror, so a
25
+ remote box's rows show the same title with no per-row SSH. Until the titler
26
+ reaches a session it shows your own words — never an agent line.
27
+ `agents sessions backfill titles [--session <id>] [--refresh]` runs it on
28
+ demand; `agents daemon services` can turn the service off.
29
+ - **Session rows carry a ranked secondary line (PHNX-3797).** Beside the bold
30
+ headline, every live row now folds on `importantMessage` — the single most
31
+ important recent agent message, ranked so a pending **question** or a
32
+ **needs-you** block (plan review, permission, input-required) beats generic
33
+ current activity. It rides the same `sessions watch --json` / mirror feed as the
34
+ title (via the row spread), so AGI EXT renders a bold title over a dim secondary
35
+ line with no extra query; `agents sessions --active` prints the question /
36
+ needs-you line dim beneath the row.
37
+ Source: `src/lib/session/title.ts`, `src/lib/daemon/session-title-service.ts`,
38
+ `src/lib/session/active.ts`, `src/lib/session/db.ts`, `src/lib/session/mirror.ts`,
39
+ `src/commands/sessions.ts`, `src/commands/sessions-backfill.ts`.
40
+
41
+ - 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.
42
+
43
+ - **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.
44
+
45
+ - **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`.
46
+
3
47
  ## 1.22.109
4
48
 
5
49
  - **`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 +83,10 @@
39
83
 
40
84
  - **`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
85
 
86
+ - **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.
87
+
88
+ - **`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.
89
+
42
90
  ## 1.22.108
43
91
 
44
92
  - 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) => {