@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
@@ -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
  }
@@ -0,0 +1,12 @@
1
+ import type { Command } from 'commander';
2
+ interface BackupSetupOptions {
3
+ bundle: string;
4
+ worker: string;
5
+ bucket: string;
6
+ account?: string;
7
+ token?: string;
8
+ domain: string;
9
+ }
10
+ export declare function handleSessionsBackupSetup(opts: BackupSetupOptions): Promise<void>;
11
+ export declare function registerSessionsBackupSetupCommand(sessionsCmd: Command): void;
12
+ export {};
@@ -0,0 +1,65 @@
1
+ // `agents sessions backup-setup` — the OPERATOR command that provisions the
2
+ // managed session-backup endpoint (`sessions.agents-cli.sh`): the Cloudflare
3
+ // Worker + R2 bucket a signed-in user's `sessions export --to-r2` talks to with
4
+ // NO `r2.backups` bucket of their own. It is the deploy producer for that
5
+ // endpoint — first-party infrastructure, not a per-user step. The zero-knowledge
6
+ // `--byo` backup path uses the user's own R2 bucket directly and never touches
7
+ // this Worker, so there is nothing here for an ordinary user to run.
8
+ //
9
+ // Mirrors `agents traces setup`: the same `readCloudflareCreds` bundle plumbing,
10
+ // the same idempotent `deployWorker`/`createBucket` primitives. Provisioning is
11
+ // idempotent — re-running redeploys the current Worker template in place.
12
+ import chalk from 'chalk';
13
+ import { DEFAULT_CF_BUNDLE, readCloudflareCreds } from '../lib/share/config.js';
14
+ import { PHOENIX_ID_BASE } from '../lib/identity/client.js';
15
+ import { provisionSessions } from '../lib/session/sync/provision.js';
16
+ import { DEFAULT_SESSIONS_BUCKET_NAME, DEFAULT_SESSIONS_DOMAIN, DEFAULT_SESSIONS_WORKER_NAME, } from '../lib/session/sync/managed-config.js';
17
+ import { setHelpSections } from '../lib/help.js';
18
+ export async function handleSessionsBackupSetup(opts) {
19
+ const { input } = await import('@inquirer/prompts');
20
+ const { apiToken, accountId: bundledAccountId } = readCloudflareCreds(opts.bundle, {
21
+ apiToken: opts.token,
22
+ accountId: opts.account,
23
+ });
24
+ const accountId = opts.account ?? bundledAccountId ?? await input({ message: 'Cloudflare account id' });
25
+ if (!accountId)
26
+ throw new Error('A Cloudflare account id is required.');
27
+ const result = await provisionSessions({
28
+ apiToken,
29
+ accountId,
30
+ workerName: opts.worker,
31
+ bucketName: opts.bucket,
32
+ domain: opts.domain,
33
+ phoenixIdBase: PHOENIX_ID_BASE,
34
+ });
35
+ console.log(chalk.green(`Managed session-backup endpoint ready → ${chalk.bold(result.baseUrl)}`));
36
+ console.log(chalk.dim('Signed-in users now back up with `agents sessions export --to-r2` — no r2.backups bucket.'));
37
+ }
38
+ const BACKUP_SETUP_EXAMPLES = `
39
+ $ agents secrets exec cloudflare -- agents sessions backup-setup
40
+ Provision the managed session-backup Worker + R2 bucket (creds from the bundle).
41
+
42
+ $ agents sessions backup-setup --account <id> --domain sessions.example.com
43
+ Provision against a private account service and custom domain.
44
+ `.trimStart();
45
+ export function registerSessionsBackupSetupCommand(sessionsCmd) {
46
+ const cmd = sessionsCmd
47
+ .command('backup-setup')
48
+ .description('(operator) Provision the managed session-backup Worker + R2 bucket — NOT a per-user step; signing in with `agents auth login` backs sessions up with zero setup')
49
+ .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
50
+ .option('--worker <name>', 'Worker name', DEFAULT_SESSIONS_WORKER_NAME)
51
+ .option('--bucket <name>', 'R2 bucket name', DEFAULT_SESSIONS_BUCKET_NAME)
52
+ .option('--account <id>', 'Cloudflare account id (else read from the bundle / prompt)')
53
+ .option('--token <token>', 'Cloudflare API token (else read from the --bundle)')
54
+ .option('--domain <host>', 'custom domain to map', DEFAULT_SESSIONS_DOMAIN)
55
+ .action(async (opts) => {
56
+ try {
57
+ await handleSessionsBackupSetup(opts);
58
+ }
59
+ catch (err) {
60
+ console.error(chalk.red(err.message));
61
+ process.exitCode = 1;
62
+ }
63
+ });
64
+ setHelpSections(cmd, { examples: BACKUP_SETUP_EXAMPLES });
65
+ }
@@ -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,6 +1,6 @@
1
- import type { Command } from 'commander';
1
+ import { type Command } from 'commander';
2
2
  import { type SessionMeta } from '../lib/session/types.js';
3
- import { type Backend, type EngineContext, type Packing } from '../lib/terminal/index.js';
3
+ import { type Backend, type SurfaceItem, type EngineContext, type Packing } from '../lib/terminal/index.js';
4
4
  import { type StrictResumeOptions } from './resume.js';
5
5
  /** Opening more than this many live sessions at once asks for confirmation first. */
6
6
  export declare const CONFIRM_THRESHOLD = 5;
@@ -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[];
@@ -33,6 +35,15 @@ export declare function isDirectResumeSelector(query: string): boolean;
33
35
  /** Re-enter through sessions resume so fleet routing and harness policy
34
36
  * stay centralized. The child inherits this terminal for a real interactive resume. */
35
37
  export declare function resumeSelectorInPlace(selector: string): Promise<void>;
38
+ /**
39
+ * Local preflight cannot inspect a peer's files or index. Leave peer validation
40
+ * to the existing origin-device recovery hop. --here without a remote surface
41
+ * opts into local recovery and therefore uses the local transcript guard.
42
+ */
43
+ export declare function partitionResumableSelections(chosen: SessionMeta[], options?: Pick<ResumeOptions, 'here' | 'device'>): {
44
+ resumable: SessionMeta[];
45
+ skipped: SessionMeta[];
46
+ };
36
47
  /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
37
48
  * live fleet, attaches a healthy pane, and falls through to `agents resume`
38
49
  * only when the process is no longer attachable. */
@@ -42,6 +53,10 @@ export declare function resumeUsesLifecycleDispatch(query: string | undefined, p
42
53
  export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): Promise<void>;
43
54
  export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): string[];
44
55
  export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
56
+ /** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
57
+ export declare function buildSelectedResumeSurface(session: SessionMeta, prompt: string | undefined, options: ResumeOptions): SurfaceItem & {
58
+ session: SessionMeta;
59
+ };
45
60
  export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
46
61
  /**
47
62
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
@@ -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';
@@ -19,16 +20,18 @@ import { multiItemPicker, itemPicker } from '../lib/picker.js';
19
20
  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
- import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
23
- import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
23
+ import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, shellQuote, } from '../lib/terminal/index.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';
27
28
  import { looksLikeSessionId } from '../lib/session/discover.js';
28
29
  import { machineId } from '../lib/session/sync/config.js';
29
- import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
30
+ import { sessionOriginDevice, sessionRecoveryDestinationMatches, sessionRecoveryPeer, sessionTranscriptReadable } from '../lib/session/recovery.js';
30
31
  import { buildResumeRemoteArgs, runStrictResume, wantsStrictResume } from './resume.js';
32
+ import { toRemotePortable } from '../lib/project-root.js';
31
33
  import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
34
+ import { sessionHeadline } from '../lib/session/title.js';
32
35
  /** Opening more than this many live sessions at once asks for confirmation first. */
33
36
  export const CONFIRM_THRESHOLD = 5;
34
37
  export function registerSessionsResumeCommand(sessionsCmd) {
@@ -44,6 +47,8 @@ export function registerSessionsResumeCommand(sessionsCmd) {
44
47
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
45
48
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
46
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]))
47
52
  .option('--device <alias>', 'Open on the session origin device over SSH; the device must match every selected session')
48
53
  .option('--iterm', 'Force the iTerm backend')
49
54
  .option('--ghostty', 'Force the Ghostty backend')
@@ -98,13 +103,39 @@ export function registerSessionsResumeCommand(sessionsCmd) {
98
103
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --device too).
99
104
  - --device <alias> opens the terminal surface on that device only when it is the selected sessions' origin; recovery never migrates a session to another device.
100
105
  - Recovery uses the installed harness and the conversation's account on its origin device. Context replay requires an explicit choice.
106
+ - Local picks without transcripts are skipped before opening a tab. Remote picks are validated on their origin device.
101
107
  - agents run claude --resume opens this same picker; claude#work filters it with --account work.
102
108
  `,
103
109
  });
104
110
  cmd.action(async (query, prompt, options) => {
105
- await sessionsResumeAction(query, prompt, options);
111
+ await sessionsResumeAction(query, prompt, resolveResumeOptions(cmd, options));
106
112
  });
107
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
+ }
108
139
  export async function sessionsResumeAction(query, prompt, options) {
109
140
  if (options.attachOnly && (prompt !== undefined || options.agent || options.account || options.model || options.mode || options.interactive || options.headless || options.cwd || options.here)) {
110
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.');
@@ -232,12 +263,16 @@ export async function sessionsResumeAction(query, prompt, options) {
232
263
  }
233
264
  }
234
265
  // 2. Route every selection through the owning device's recovery resolver.
235
- const items = [];
236
- for (const s of chosen) {
237
- const command = ['agents', ...buildSelectedResumeArgs(s.id, prompt, options)];
238
- const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
239
- items.push({ session: s, cwd, command });
266
+ const { resumable, skipped } = partitionResumableSelections(chosen, options);
267
+ for (const s of skipped) {
268
+ console.log(chalk.yellow(`Skipping ${s.shortId} — nothing to resume (no transcript was written).`));
240
269
  }
270
+ if (resumable.length === 0) {
271
+ console.error(chalk.red('Nothing to resume — no selected session has a transcript.'));
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+ const items = resumable.map((session) => buildSelectedResumeSurface(session, prompt, options));
241
276
  // 3. Resolve the backend (and host).
242
277
  const ctx = currentContext();
243
278
  const backend = await resolveBackend(options, ctx, items.length);
@@ -279,7 +314,7 @@ export async function sessionsResumeAction(query, prompt, options) {
279
314
  command: it.command,
280
315
  agent: it.session.agent || undefined,
281
316
  sessionId: it.session.id || undefined,
282
- title: it.session.label || it.session.topic || undefined,
317
+ title: sessionHeadline(it.session),
283
318
  })), { backend, host: options.device, packing });
284
319
  let opened = 0;
285
320
  results.forEach((r, i) => {
@@ -294,6 +329,8 @@ export async function sessionsResumeAction(query, prompt, options) {
294
329
  }
295
330
  });
296
331
  console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
332
+ if (opened !== items.length)
333
+ process.exitCode = 1;
297
334
  }
298
335
  /** Preserve run options and lifecycle intent when the picker opens its selected rows. */
299
336
  export function buildSelectedResumeArgs(id, prompt, options) {
@@ -307,6 +344,7 @@ export function buildSelectedResumeArgs(id, prompt, options) {
307
344
  // The outer surface already placed this terminal on the selected device.
308
345
  if (options.device) {
309
346
  let remoteCwd;
347
+ let localCwd;
310
348
  for (let i = 0; i < args.length && args[i] !== '--'; i++) {
311
349
  if (args[i] === '--remote-cwd') {
312
350
  remoteCwd = args[i + 1];
@@ -316,6 +354,14 @@ export function buildSelectedResumeArgs(id, prompt, options) {
316
354
  remoteCwd = args[i].slice('--remote-cwd='.length);
317
355
  args.splice(i--, 1);
318
356
  }
357
+ else if (args[i] === '--cwd') {
358
+ localCwd = args[i + 1];
359
+ args.splice(i--, 2);
360
+ }
361
+ else if (args[i].startsWith('--cwd=')) {
362
+ localCwd = args[i].slice('--cwd='.length);
363
+ args.splice(i--, 1);
364
+ }
319
365
  else if (['-D', '--device', '--host', '--where', '--on', '--computer'].includes(args[i])) {
320
366
  args.splice(i, 2);
321
367
  i--;
@@ -325,9 +371,10 @@ export function buildSelectedResumeArgs(id, prompt, options) {
325
371
  i--;
326
372
  }
327
373
  }
328
- if (remoteCwd !== undefined) {
374
+ const effectiveCwd = remoteCwd ?? options.cwd ?? (localCwd ? toRemotePortable(localCwd) : undefined);
375
+ if (effectiveCwd !== undefined) {
329
376
  const end = args.indexOf('--');
330
- args.splice(end < 0 ? args.length : end, 0, '--cwd', remoteCwd);
377
+ args.splice(end < 0 ? args.length : end, 0, '--cwd', effectiveCwd);
331
378
  }
332
379
  }
333
380
  return args;
@@ -352,6 +399,20 @@ export function isDirectResumeSelector(query) {
352
399
  export async function resumeSelectorInPlace(selector) {
353
400
  await spawnCliInPlace(['sessions', 'resume', selector]);
354
401
  }
402
+ /**
403
+ * Local preflight cannot inspect a peer's files or index. Leave peer validation
404
+ * to the existing origin-device recovery hop. --here without a remote surface
405
+ * opts into local recovery and therefore uses the local transcript guard.
406
+ */
407
+ export function partitionResumableSelections(chosen, options = {}) {
408
+ const resumable = [];
409
+ const skipped = [];
410
+ for (const s of chosen) {
411
+ const deferredToPeer = (!options.here || !!options.device) && sessionRecoveryPeer(s);
412
+ (deferredToPeer || sessionTranscriptReadable(s) ? resumable : skipped).push(s);
413
+ }
414
+ return { resumable, skipped };
415
+ }
355
416
  /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
356
417
  * live fleet, attaches a healthy pane, and falls through to `agents resume`
357
418
  * only when the process is no longer attachable. */
@@ -395,6 +456,21 @@ async function spawnCliInPlace(args) {
395
456
  export function resolveResumePacking(options) {
396
457
  return options.splits ? 'two-per-tab' : 'tabs';
397
458
  }
459
+ /** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
460
+ export function buildSelectedResumeSurface(session, prompt, options) {
461
+ const requestedCwd = options.cwd || session.cwd;
462
+ if (options.device && !requestedCwd) {
463
+ throw new Error(`Cannot open ${session.shortId} on ${options.device} without a recorded working directory. Pass --cwd <path>.`);
464
+ }
465
+ const cwd = options.device
466
+ ? requestedCwd
467
+ : requestedCwd && fs.existsSync(requestedCwd) ? requestedCwd : process.cwd();
468
+ return {
469
+ session,
470
+ cwd,
471
+ command: ['agents', ...buildSelectedResumeArgs(session.id, prompt, options)].map(shellQuote),
472
+ };
473
+ }
398
474
  export function resumeHostMismatch(session, requestedHost, self = machineId()) {
399
475
  const origin = sessionOriginDevice(session, self);
400
476
  return sessionRecoveryDestinationMatches(session, requestedHost, self)