@phnx-labs/agents-cli 1.22.105 → 1.22.107

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 (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +28 -6
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.d.ts +78 -8
  5. package/dist/commands/exec.js +407 -284
  6. package/dist/commands/resume.d.ts +6 -21
  7. package/dist/commands/resume.js +18 -55
  8. package/dist/commands/run-account-picker.d.ts +11 -0
  9. package/dist/commands/run-account-picker.js +11 -1
  10. package/dist/commands/sessions-resume.d.ts +4 -0
  11. package/dist/commands/sessions-resume.js +132 -49
  12. package/dist/commands/sessions.js +32 -5
  13. package/dist/index.js +0 -0
  14. package/dist/lib/accounting/account-launch.d.ts +54 -0
  15. package/dist/lib/accounting/account-launch.js +117 -0
  16. package/dist/lib/accounting/account-pool-collect.js +2 -1
  17. package/dist/lib/accounting/account-pool.d.ts +2 -0
  18. package/dist/lib/accounting/account-pool.js +1 -0
  19. package/dist/lib/accounting/rotate.d.ts +32 -6
  20. package/dist/lib/accounting/rotate.js +70 -42
  21. package/dist/lib/accounting/usage.d.ts +71 -0
  22. package/dist/lib/accounting/usage.js +160 -11
  23. package/dist/lib/exec-account-home.d.ts +3 -1
  24. package/dist/lib/exec-account-home.js +2 -2
  25. package/dist/lib/exec.d.ts +27 -1
  26. package/dist/lib/exec.js +150 -27
  27. package/dist/lib/hosts/dispatch.d.ts +1 -1
  28. package/dist/lib/hosts/dispatch.js +1 -1
  29. package/dist/lib/models.d.ts +1 -1
  30. package/dist/lib/models.js +4 -4
  31. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  32. package/dist/lib/session/actor-sidecar.js +3 -0
  33. package/dist/lib/session/claude-accounts.d.ts +12 -73
  34. package/dist/lib/session/claude-accounts.js +32 -70
  35. package/dist/lib/session/db.d.ts +1 -1
  36. package/dist/lib/session/db.js +18 -5
  37. package/dist/lib/session/discover.d.ts +4 -0
  38. package/dist/lib/session/discover.js +116 -15
  39. package/dist/lib/session/recovery.d.ts +30 -34
  40. package/dist/lib/session/recovery.js +212 -76
  41. package/dist/lib/session/types.d.ts +2 -0
  42. package/dist/lib/teams/placement-probe.js +1 -1
  43. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  44. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  45. package/dist/session-tracker/dist/hook.sh +191 -0
  46. package/dist/session-tracker/dist/index.d.ts +19 -0
  47. package/dist/session-tracker/dist/index.js +67 -0
  48. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  49. package/dist/session-tracker/dist/install-hook.js +245 -0
  50. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  51. package/dist/session-tracker/dist/prune-state.js +7 -0
  52. package/dist/session-tracker/dist/reader.d.ts +7 -0
  53. package/dist/session-tracker/dist/reader.js +151 -0
  54. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  55. package/dist/session-tracker/dist/state-file.js +119 -0
  56. package/dist/session-tracker/dist/types.d.ts +32 -0
  57. package/dist/session-tracker/dist/types.js +1 -0
  58. package/dist/session-tracker/dist/writer.d.ts +12 -0
  59. package/dist/session-tracker/dist/writer.js +27 -0
  60. package/package.json +1 -1
@@ -1,28 +1,20 @@
1
1
  import type { SessionMeta } from '../lib/session/types.js';
2
2
  export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
3
- /**
4
- * The source a dead-remote local fallback resumes from: the same session, but
5
- * with `machine` rewritten to THIS box so the delegated `agents run --resume`
6
- * resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
7
- * bouncing back to the unreachable owner. Because no local version home owns the
8
- * peer's transcript, that local recovery lands on a labelled `/continue` replay
9
- * from the synced mirror — the only way to continue a session whose owning device
10
- * is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
11
- *
12
- * Safe against the RUSH-2022 "silent local resume forks live state" hazard by
13
- * PRECONDITION: this is reached only after `runOnPeer` proved the owner
14
- * unreachable, so there is no live process on the peer to fork, and the fallback
15
- * is announced with a log line — never silent.
16
- */
3
+ /** Inspect local archived context after the owner hop fails. Recovery still
4
+ * requires readable content and explicit replay consent before starting a run. */
17
5
  export declare function resumeLocalFallbackSource(session: SessionMeta, self?: string): SessionMeta;
18
6
  export interface StrictResumeOptions {
7
+ agent?: string;
19
8
  mode?: string;
9
+ account?: string;
10
+ model?: string;
20
11
  interactive?: boolean;
21
12
  headless?: boolean;
22
13
  cwd?: string;
23
14
  quiet?: boolean;
24
15
  /** Run on THIS machine even when the session belongs to a peer (escape hatch). */
25
16
  here?: boolean;
17
+ local?: boolean;
26
18
  }
27
19
  /**
28
20
  * The argv to re-run this resume on the machine that owns the session.
@@ -40,13 +32,6 @@ export declare function buildResumeRunArgs(session: {
40
32
  agent: string;
41
33
  version?: string;
42
34
  }, prompt: string | undefined, options: StrictResumeOptions): string[];
43
- /** Recreate a remote Claude launch whose forced id never materialized a transcript. */
44
- export declare function buildProvisionalRunArgs(session: {
45
- id: string;
46
- agent: string;
47
- version?: string;
48
- cwd?: string;
49
- }, prompt: string | undefined, options: StrictResumeOptions): string[];
50
35
  /** True when the caller asked for the strict resume path (prompt and/or flags). */
51
36
  export declare function wantsStrictResume(prompt: string | undefined, options: StrictResumeOptions): boolean;
52
37
  /**
@@ -9,20 +9,8 @@ import { resolveSessionMetadataValue } from './sessions.js';
9
9
  import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
10
10
  import { machineId } from '../lib/machine-id.js';
11
11
  export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
12
- /**
13
- * The source a dead-remote local fallback resumes from: the same session, but
14
- * with `machine` rewritten to THIS box so the delegated `agents run --resume`
15
- * resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
16
- * bouncing back to the unreachable owner. Because no local version home owns the
17
- * peer's transcript, that local recovery lands on a labelled `/continue` replay
18
- * from the synced mirror — the only way to continue a session whose owning device
19
- * is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
20
- *
21
- * Safe against the RUSH-2022 "silent local resume forks live state" hazard by
22
- * PRECONDITION: this is reached only after `runOnPeer` proved the owner
23
- * unreachable, so there is no live process on the peer to fork, and the fallback
24
- * is announced with a log line — never silent.
25
- */
12
+ /** Inspect local archived context after the owner hop fails. Recovery still
13
+ * requires readable content and explicit replay consent before starting a run. */
26
14
  export function resumeLocalFallbackSource(session, self = machineId()) {
27
15
  return { ...session, machine: self };
28
16
  }
@@ -36,6 +24,12 @@ export function resumeLocalFallbackSource(session, self = machineId()) {
36
24
  */
37
25
  export function buildResumeRemoteArgs(sessionId, prompt, options) {
38
26
  const args = ['sessions', 'resume', sessionId, ...(prompt === undefined ? [] : [prompt])];
27
+ if (options.agent)
28
+ args.push('--agent', options.agent);
29
+ if (options.account)
30
+ args.push('--account', options.account);
31
+ if (options.model)
32
+ args.push('--model', options.model);
39
33
  if (options.mode)
40
34
  args.push('--mode', options.mode);
41
35
  if (options.interactive)
@@ -51,8 +45,12 @@ export function buildResumeRemoteArgs(sessionId, prompt, options) {
51
45
  /** Translate `agents sessions resume <id>` to the canonical `agents run` surface after
52
46
  * resolving the source identity. The run command remains the only executor. */
53
47
  export function buildResumeRunArgs(session, prompt, options) {
54
- const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
48
+ const spec = session.agent;
55
49
  const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--resume', session.id];
50
+ if (options.account)
51
+ args.push('--account', options.account);
52
+ if (options.model)
53
+ args.push('--model', options.model);
56
54
  if (options.mode)
57
55
  args.push('--mode', options.mode);
58
56
  if (options.interactive)
@@ -65,23 +63,6 @@ export function buildResumeRunArgs(session, prompt, options) {
65
63
  args.push('--quiet');
66
64
  return args;
67
65
  }
68
- /** Recreate a remote Claude launch whose forced id never materialized a transcript. */
69
- export function buildProvisionalRunArgs(session, prompt, options) {
70
- const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
71
- const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--session-id', session.id];
72
- if (options.mode)
73
- args.push('--mode', options.mode);
74
- if (options.interactive)
75
- args.push('--interactive');
76
- if (options.headless)
77
- args.push('--headless');
78
- const cwd = options.cwd ?? session.cwd;
79
- if (cwd)
80
- args.push('--cwd', cwd);
81
- if (options.quiet)
82
- args.push('--quiet');
83
- return args;
84
- }
85
66
  function consumeResumeSource() {
86
67
  const raw = process.env[RESUME_SOURCE_ENV];
87
68
  delete process.env[RESUME_SOURCE_ENV];
@@ -124,7 +105,7 @@ export async function runStrictResume(sessionId, prompt, options) {
124
105
  }
125
106
  // An owner hop must inspect only the owner's index. Fleet fan-out here can
126
107
  // rediscover the dispatcher's synthetic row and bounce the same id forever.
127
- const outcome = await resolveSessionMetadataValue(sessionId.trim(), pinnedHere ? { local: true } : {});
108
+ const outcome = await resolveSessionMetadataValue(sessionId.trim(), { agent: options.agent, local: pinnedHere || options.local });
128
109
  if (outcome.kind === 'partial') {
129
110
  // RUSH-2492: an unreachable peer is a warning, not a hard failure. The
130
111
  // resolver already resolves an id found on the reachable fleet (SES-9a),
@@ -138,19 +119,6 @@ export async function runStrictResume(sessionId, prompt, options) {
138
119
  return;
139
120
  }
140
121
  if (outcome.kind === 'not-found') {
141
- if (routedHop && routedSource?.filePath === '' && routedSource.agent === 'claude') {
142
- const args = buildProvisionalRunArgs(routedSource, prompt, options);
143
- const child = spawn(process.execPath, [process.argv[1], ...args], {
144
- stdio: 'inherit',
145
- env: process.env,
146
- });
147
- const exitCode = await new Promise((resolve) => {
148
- child.once('error', () => resolve(127));
149
- child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
150
- });
151
- process.exitCode = exitCode;
152
- return;
153
- }
154
122
  console.error(chalk.red(`No session matching "${sessionId}".`));
155
123
  process.exitCode = 1;
156
124
  return;
@@ -184,21 +152,16 @@ export async function runStrictResume(sessionId, prompt, options) {
184
152
  sessionId: outcome.session.id,
185
153
  });
186
154
  if (rc === 'no-target' || rc === 'unreachable') {
187
- // Prefer-device, fall back to local (PHNX-3626): the owning device is
188
- // unreachable — either not a dialable registered device ('no-target') or
189
- // registered but offline/asleep so the SSH connection itself failed
190
- // ('unreachable'). Either way there is no live harness to reach OR to fork,
191
- // so continue the session HERE from its synced mirror rather than
192
- // dead-ending. The local recovery resolves this to a labelled `/continue`
193
- // replay (no local home owns the peer's transcript), the honest degradation.
155
+ // The owner may still be running. Only verified local content can be
156
+ // offered as a separate conversation after explicit replay consent.
194
157
  if (!options.quiet) {
195
- process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable → resuming locally (/continue replay from the synced transcript)\n`));
158
+ process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable; checking for local transcript content before offering replay\n`));
196
159
  }
197
160
  process.exitCode = await delegateLocalResume(resumeLocalFallbackSource(outcome.session), prompt, options);
198
161
  }
199
162
  return;
200
163
  }
201
- process.exitCode = await delegateLocalResume(outcome.session, prompt, options);
164
+ process.exitCode = await delegateLocalResume(pinnedHere ? resumeLocalFallbackSource(outcome.session) : outcome.session, prompt, options);
202
165
  }
203
166
  /**
204
167
  * Spawn the delegated local `agents run --resume` for a session this box owns
@@ -35,6 +35,17 @@ export declare function buildSwitchAccountChoices(rows: SwitchAccountRow[]): Run
35
35
  * A cancelled picker writes nothing.
36
36
  */
37
37
  export declare function pickSwitchAccount(agent: AgentId, rows: SwitchAccountRow[]): Promise<string | null>;
38
+ /**
39
+ * The two-condition "human-facing" gate behind signInLaunchDecision and
40
+ * noVerifiedUsageDecision: a real TTY and no `--json`. Off a TTY nobody can
41
+ * answer a prompt, and `--json` marks a MACHINE consumer, which must never be
42
+ * handed a picker or dropped into a login TUI. Mirrors the canonical
43
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`.
44
+ */
45
+ export declare function isHumanFacingRun(input: {
46
+ tty: boolean;
47
+ json: boolean;
48
+ }): boolean;
38
49
  /**
39
50
  * Whether a zero-healthy run may recover by launching for a login, or must keep
40
51
  * failing loud. Three inputs, all of which have to hold:
@@ -204,6 +204,16 @@ export async function pickSwitchAccount(agent, rows) {
204
204
  throw err;
205
205
  }
206
206
  }
207
+ /**
208
+ * The two-condition "human-facing" gate behind signInLaunchDecision and
209
+ * noVerifiedUsageDecision: a real TTY and no `--json`. Off a TTY nobody can
210
+ * answer a prompt, and `--json` marks a MACHINE consumer, which must never be
211
+ * handed a picker or dropped into a login TUI. Mirrors the canonical
212
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`.
213
+ */
214
+ export function isHumanFacingRun(input) {
215
+ return input.tty && !input.json;
216
+ }
207
217
  /**
208
218
  * Whether a zero-healthy run may recover by launching for a login, or must keep
209
219
  * failing loud. Three inputs, all of which have to hold:
@@ -217,7 +227,7 @@ export async function pickSwitchAccount(agent, rows) {
217
227
  * gets the parseable fail-loud error instead.
218
228
  */
219
229
  export function signInLaunchDecision(input) {
220
- const humanPresent = input.tty && !input.json;
230
+ const humanPresent = isHumanFacingRun(input);
221
231
  return input.recoverable > 0 && humanPresent ? 'launch' : 'fail-loud';
222
232
  }
223
233
  /**
@@ -20,9 +20,13 @@ export interface ResumeOptions extends StrictResumeOptions {
20
20
  splits?: boolean;
21
21
  attachOnly?: boolean;
22
22
  local?: boolean;
23
+ /** Original run argv; only the bare resume selector changes after picking. */
24
+ runArgs?: string[];
23
25
  }
24
26
  export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
25
27
  export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
28
+ /** Preserve run options and lifecycle intent when the picker opens its selected rows. */
29
+ export declare function buildSelectedResumeArgs(id: string, prompt: string | undefined, options: ResumeOptions): string[];
26
30
  /** IDs and tmux aliases are actions, not picker search text. Human phrases keep
27
31
  * the existing pre-filtered picker, while an explicit identity resumes directly. */
28
32
  export declare function isDirectResumeSelector(query: string): boolean;
@@ -17,7 +17,7 @@ import { discoverSessions } from '../lib/session/discover.js';
17
17
  import { filterTeamSessions } from '../lib/session/team-filter.js';
18
18
  import { multiItemPicker, itemPicker } from '../lib/picker.js';
19
19
  import { buildPreview } from './sessions-picker.js';
20
- import { formatPickerLabel, pickerColumnsFor, buildSessionRecoveryCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
20
+ import { formatPickerLabel, pickerColumnsFor, resolveSessionMetadataValue, parseAgentFilter, } from './sessions.js';
21
21
  import { sessionMatchesQuery } from './sessions-browser.js';
22
22
  import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
23
23
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -27,7 +27,7 @@ import { spawn } from 'node:child_process';
27
27
  import { looksLikeSessionId } from '../lib/session/discover.js';
28
28
  import { machineId } from '../lib/session/sync/config.js';
29
29
  import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
30
- import { runStrictResume, wantsStrictResume } from './resume.js';
30
+ import { buildResumeRemoteArgs, runStrictResume, wantsStrictResume } from './resume.js';
31
31
  import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
32
32
  /** Opening more than this many live sessions at once asks for confirmation first. */
33
33
  export const CONFIRM_THRESHOLD = 5;
@@ -35,9 +35,11 @@ export function registerSessionsResumeCommand(sessionsCmd) {
35
35
  const cmd = sessionsCmd
36
36
  .command('resume')
37
37
  .argument('[query]', 'Session id/tmux alias/label for strict resume, or text that filters the picker')
38
- .argument('[prompt]', 'Optional follow-up prompt (strict resume with original harness/version/device)')
38
+ .argument('[prompt]', 'Optional follow-up prompt (same conversation, account and origin device)')
39
39
  .description('Resume a session by id (strict), or multi-select history into terminal tabs/splits.')
40
40
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
41
+ .option('--account <account>', 'Filter by the account that owns the conversation')
42
+ .option('--model <model>', 'Override the resumed session model')
41
43
  .option('--all', 'Include sessions from every directory (not just current project)')
42
44
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
43
45
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
@@ -84,7 +86,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
84
86
  agents sessions resume --device zion --tmux
85
87
  `,
86
88
  notes: `
87
- - Strict path (id/tmux alias/label + optional prompt/--mode/--headless/--here): restores original harness, version, device, account, cwd, and mode. Searches the fleet; a local full-id hit resumes with zero SSH. Replaces the former top-level agents sessions resume.
89
+ - Strict path (id/tmux alias/label + optional prompt/--mode/--headless/--here): restores the harness, device, account, cwd, model, and mode using the installed binary. Searches the fleet; a local full-id hit resumes with zero SSH. Replaces the former top-level agents sessions resume.
88
90
  - Attach a live pane without forking: agents sessions focus <id>.
89
91
  - This is the ONE verb for getting back in. It detects the state: a live tmux pane is attached, a headless session comes to the foreground, an ended one recovers on its owning device.
90
92
  - A UUID/prefix or ag-<agent>-<suffix> alias bypasses the picker. A live alias attaches by name even when the session index cannot attribute it.
@@ -95,7 +97,8 @@ export function registerSessionsResumeCommand(sessionsCmd) {
95
97
  - Backend: auto-detected from the terminal you're in (iTerm / Ghostty / tmux); override with --iterm/--ghostty/--tmux/--vscodium.
96
98
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --device too).
97
99
  - --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.
98
- - Recovery runs on the session's origin device: exact healthy origin uses native resume; otherwise a healthy version of the same harness receives /continue <id>.
100
+ - Recovery uses the installed harness and the conversation's account on its origin device. Context replay requires an explicit choice.
101
+ - agents run claude --resume opens this same picker; claude#work filters it with --account work.
99
102
  `,
100
103
  });
101
104
  cmd.action(async (query, prompt, options) => {
@@ -103,19 +106,28 @@ export function registerSessionsResumeCommand(sessionsCmd) {
103
106
  });
104
107
  }
105
108
  export async function sessionsResumeAction(query, prompt, options) {
109
+ if (options.attachOnly && (prompt !== undefined || options.agent || options.account || options.model || options.mode || options.interactive || options.headless || options.cwd || options.here)) {
110
+ 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.');
111
+ }
106
112
  const strictOpts = {
113
+ agent: parseAgentFilter(options.agent).agent,
114
+ account: options.account,
115
+ model: options.model,
107
116
  mode: options.mode,
108
117
  interactive: options.interactive,
109
118
  headless: options.headless,
110
119
  cwd: options.cwd,
111
120
  quiet: options.quiet,
112
121
  here: options.here,
122
+ local: options.local,
113
123
  };
124
+ const explicitSurface = !!(options.iterm || options.ghostty || options.tmux || options.vscodium || options.terminalApp || options.device);
125
+ const direct = !!query && (isDirectResumeSelector(query) || wantsStrictResume(prompt, strictOpts));
114
126
  // Direct id/alias (or label with prompt/strict flags) → strict resume
115
127
  // (former top-level `agents sessions resume`). `--attach-only` / `--local`
116
128
  // must go through sessions focus so they cannot silently fork a copy
117
129
  // (AGI EXT still shells `sessions resume <id> --local`).
118
- if (query && (isDirectResumeSelector(query) || wantsStrictResume(prompt, strictOpts))) {
130
+ if (query && direct && !explicitSurface) {
119
131
  const hosts = options.device ? [options.device] : [];
120
132
  // PHNX-3292: a live LOCAL tmux pane (the exact alias, or a unique 8-hex
121
133
  // short id) attaches immediately, before any fleet SSH — the product rule
@@ -124,62 +136,86 @@ export async function sessionsResumeAction(query, prompt, options) {
124
136
  // `--attach-only`: a prompt/mode/headless/cwd override still means the
125
137
  // caller wants strict resume semantics (a scripted continue), so that
126
138
  // case is excluded via wantsStrictResume.
127
- if (!wantsStrictResume(prompt, strictOpts) && await attachLocalLiveSelector(query.trim(), hosts)) {
139
+ if (!options.agent && !options.account && !options.model && !wantsStrictResume(prompt, strictOpts) && await attachLocalLiveSelector(query.trim(), hosts)) {
128
140
  return;
129
141
  }
130
- if (resumeUsesLifecycleDispatch(query, prompt, options)) {
142
+ if (!options.agent && !options.account && !options.model && resumeUsesLifecycleDispatch(query, prompt, options)) {
131
143
  await dispatchSessionLifecycleInPlace(query.trim(), hosts, !!options.attachOnly, !!options.local);
132
144
  return;
133
145
  }
134
146
  await runStrictResume(query.trim(), prompt, strictOpts);
135
147
  return;
136
148
  }
137
- if (!isInteractiveTerminal()) {
149
+ if (!direct && !isInteractiveTerminal()) {
138
150
  console.error(chalk.red('sessions resume needs an interactive terminal (or pass a session id for strict resume).'));
139
151
  process.exitCode = 1;
140
152
  return;
141
153
  }
142
- const { agent, version } = parseAgentFilter(options.agent);
143
- const limit = parseInt(options.limit || '200', 10);
144
- const since = options.since ?? (options.all ? undefined : '30d');
145
- let sessions = await discoverSessions({
146
- agent,
147
- version,
148
- all: options.all,
149
- cwd: process.cwd(),
150
- since,
151
- sortBy: 'timestamp',
152
- limit,
153
- excludeTeamOrigin: !options.teams,
154
- });
155
- const { visible } = filterTeamSessions(sessions, !!options.teams);
156
- sessions = visible;
157
- if (sessions.length === 0) {
158
- console.log(chalk.gray('No sessions found. Try --all or a different --since window.'));
159
- return;
160
- }
161
- // 1. Multi-select the sessions. gutter: 6 = the multi-select cursor + checkbox
162
- // ('> [x] ') that multiItemPicker prepends, so rows size to fit without wrapping.
163
- const cols = { ...pickerColumnsFor(sessions), gutter: 6 };
164
154
  let chosen;
165
- try {
166
- chosen = await multiItemPicker({
167
- message: 'Select sessions to resume:',
168
- items: sessions,
169
- filter: (q) => (q.trim() ? sessions.filter((s) => sessionMatchesQuery(s, q)) : sessions),
170
- labelFor: (s, q) => formatPickerLabel(s, q, cols),
171
- keyFor: (s) => s.id,
172
- buildPreview,
173
- pageSize: 15,
174
- initialSearch: query,
175
- emptyMessage: 'No sessions match.',
176
- enterHint: 'resume',
177
- });
155
+ if (query && direct) {
156
+ const outcome = await resolveSessionMetadataValue(query, { local: options.local, hosts: options.device ? [options.device] : undefined, agent: parseAgentFilter(options.agent).agent });
157
+ if (outcome.kind !== 'resolved') {
158
+ console.error(chalk.red(`No session matching "${query}" (${outcome.kind}).`));
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ chosen = [outcome.session];
178
163
  }
179
- catch (err) {
180
- if (isPromptCancelled(err))
164
+ else {
165
+ const { agent, version } = parseAgentFilter(options.agent);
166
+ const limit = parseInt(options.limit || '200', 10);
167
+ const since = options.since ?? (options.all ? undefined : '30d');
168
+ let sessions = await discoverSessions({
169
+ agent,
170
+ version,
171
+ all: options.all,
172
+ cwd: process.cwd(),
173
+ since,
174
+ sortBy: 'timestamp',
175
+ limit: options.account ? undefined : limit,
176
+ unbounded: !!options.account,
177
+ excludeTeamOrigin: !options.teams,
178
+ });
179
+ const { visible } = filterTeamSessions(sessions, !!options.teams);
180
+ sessions = visible;
181
+ if (options.account) {
182
+ const { findUnifiedAccount } = await import('../lib/account-registry.js');
183
+ const { readMeta } = await import('../lib/state.js');
184
+ const { sessionMatchesAccount } = await import('../lib/session/recovery.js');
185
+ const account = findUnifiedAccount(options.account, readMeta(), undefined, agent);
186
+ if (!account)
187
+ throw new Error(`Unknown account '${options.account}'.`);
188
+ const context = account.kind === 'native'
189
+ ? await (await import('../lib/exec-account-home.js')).resolveNativeSpawnHome(account.agent, account, readMeta(), { readOnly: true }).catch(() => null)
190
+ : null;
191
+ sessions = sessions.filter(session => sessionMatchesAccount(session, account, context?.execHome)).slice(0, limit);
192
+ }
193
+ if (sessions.length === 0) {
194
+ console.log(chalk.gray('No sessions found. Try --all or a different --since window.'));
181
195
  return;
182
- throw err;
196
+ }
197
+ // 1. Multi-select the sessions. gutter: 6 = the multi-select cursor + checkbox
198
+ // ('> [x] ') that multiItemPicker prepends, so rows size to fit without wrapping.
199
+ const cols = { ...pickerColumnsFor(sessions), gutter: 6 };
200
+ try {
201
+ chosen = await multiItemPicker({
202
+ message: 'Select sessions to resume:',
203
+ items: sessions,
204
+ filter: (q) => (q.trim() ? sessions.filter((s) => sessionMatchesQuery(s, q)) : sessions),
205
+ labelFor: (s, q) => formatPickerLabel(s, q, cols),
206
+ keyFor: (s) => s.id,
207
+ buildPreview,
208
+ pageSize: 15,
209
+ initialSearch: query,
210
+ emptyMessage: 'No sessions match.',
211
+ enterHint: 'resume',
212
+ });
213
+ }
214
+ catch (err) {
215
+ if (isPromptCancelled(err))
216
+ return;
217
+ throw err;
218
+ }
183
219
  }
184
220
  if (!chosen || chosen.length === 0)
185
221
  return;
@@ -198,7 +234,7 @@ export async function sessionsResumeAction(query, prompt, options) {
198
234
  // 2. Route every selection through the owning device's recovery resolver.
199
235
  const items = [];
200
236
  for (const s of chosen) {
201
- const command = buildSessionRecoveryCommand(s, !!options.device);
237
+ const command = ['agents', ...buildSelectedResumeArgs(s.id, prompt, options)];
202
238
  const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
203
239
  items.push({ session: s, cwd, command });
204
240
  }
@@ -222,7 +258,7 @@ export async function sessionsResumeAction(query, prompt, options) {
222
258
  console.log(chalk.gray(`Resuming ${items.length} sessions one at a time (no tab-capable terminal detected).`));
223
259
  }
224
260
  for (const it of items)
225
- await resumeSessionInPlace(it.session);
261
+ await spawnCliInPlace(buildSelectedResumeArgs(it.session.id, prompt, options));
226
262
  return;
227
263
  }
228
264
  // 5b. Fan out through the engine. Full-width tabs are the default for batch
@@ -259,6 +295,52 @@ export async function sessionsResumeAction(query, prompt, options) {
259
295
  });
260
296
  console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
261
297
  }
298
+ /** Preserve run options and lifecycle intent when the picker opens its selected rows. */
299
+ export function buildSelectedResumeArgs(id, prompt, options) {
300
+ if (options.runArgs) {
301
+ const args = [...options.runArgs];
302
+ const boundary = args.indexOf('--');
303
+ const index = args.findIndex((arg, i) => (boundary < 0 || i < boundary) && (arg === '--resume' || arg === '--resume='));
304
+ if (index < 0)
305
+ throw new Error('The run command did not contain a bare --resume selector.');
306
+ args.splice(index, 1, '--resume', id);
307
+ // The outer surface already placed this terminal on the selected device.
308
+ if (options.device) {
309
+ let remoteCwd;
310
+ for (let i = 0; i < args.length && args[i] !== '--'; i++) {
311
+ if (args[i] === '--remote-cwd') {
312
+ remoteCwd = args[i + 1];
313
+ args.splice(i--, 2);
314
+ }
315
+ else if (args[i].startsWith('--remote-cwd=')) {
316
+ remoteCwd = args[i].slice('--remote-cwd='.length);
317
+ args.splice(i--, 1);
318
+ }
319
+ else if (['-D', '--device', '--host', '--where', '--on', '--computer'].includes(args[i])) {
320
+ args.splice(i, 2);
321
+ i--;
322
+ }
323
+ else if ((/^(?:--(?:device|host|where|on|computer)=|-D.)/.test(args[i]))) {
324
+ args.splice(i, 1);
325
+ i--;
326
+ }
327
+ }
328
+ if (remoteCwd !== undefined) {
329
+ const end = args.indexOf('--');
330
+ args.splice(end < 0 ? args.length : end, 0, '--cwd', remoteCwd);
331
+ }
332
+ }
333
+ return args;
334
+ }
335
+ const args = buildResumeRemoteArgs(id, prompt, options);
336
+ if (options.here)
337
+ args.push('--here');
338
+ if (options.local)
339
+ args.push('--local');
340
+ if (options.attachOnly)
341
+ args.push('--attach-only');
342
+ return args;
343
+ }
262
344
  /** IDs and tmux aliases are actions, not picker search text. Human phrases keep
263
345
  * the existing pre-filtered picker, while an explicit identity resumes directly. */
264
346
  export function isDirectResumeSelector(query) {
@@ -285,6 +367,7 @@ export function resumeUsesLifecycleDispatch(query, prompt, options) {
285
367
  interactive: options.interactive,
286
368
  headless: options.headless,
287
369
  here: options.here,
370
+ local: options.local,
288
371
  });
289
372
  }
290
373
  export async function dispatchSessionLifecycleInPlace(selector, hosts = [], attachOnly = false, local = false) {
@@ -3523,6 +3523,8 @@ function renderArchivedSession(session, mode, options = {}) {
3523
3523
  }
3524
3524
  }
3525
3525
  async function renderSession(session, mode, filters, options = {}) {
3526
+ const { hydrateSessionTranscript, findLocalSessionTranscripts } = await import('../lib/session/discover.js');
3527
+ session = await hydrateSessionTranscript(session);
3526
3528
  // OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
3527
3529
  const realPath = session.filePath.split('#')[0];
3528
3530
  if (!fs.existsSync(realPath)) {
@@ -3535,7 +3537,8 @@ async function renderSession(session, mode, filters, options = {}) {
3535
3537
  renderArchivedSession(session, mode, options);
3536
3538
  return;
3537
3539
  }
3538
- console.log(chalk.yellow('Session transcript not available (file no longer exists).'));
3540
+ process.exitCode = 1;
3541
+ console.log(chalk.yellow('Session transcript is unavailable after checking its recorded home and the session index.'));
3539
3542
  console.log(chalk.gray(`Path: ${session.filePath}`));
3540
3543
  if (session.version)
3541
3544
  console.log(chalk.gray(`Version: ${sessionDisplayAgent(session)} ${session.version}`));
@@ -4917,12 +4920,36 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
4917
4920
  const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
4918
4921
  if (!includeLocal)
4919
4922
  return [];
4920
- const indexed = resolveIndexedMetadataRows(indexedRowsForSelector(selector, scope), selector, scope);
4921
- if (indexed.length > 0 || !looksLikeSessionId(selector)) {
4923
+ let indexed = resolveIndexedMetadataRows(indexedRowsForSelector(selector, scope), selector, scope);
4924
+ if (!looksLikeSessionId(selector)) {
4922
4925
  return indexed.map(session => ({ ...session, machine: session.machine || localMachine }));
4923
4926
  }
4924
- const live = await liveMetadataMatches(selector, scope, localMachine, deps);
4925
- return live.map(session => ({ ...session, machine: session.machine || localMachine }));
4927
+ const { hydrateSessionTranscript, findLocalSessionTranscripts } = await import('../lib/session/discover.js');
4928
+ if (indexed.length === 0) {
4929
+ const disk = await findLocalSessionTranscripts(selector, scope.agent);
4930
+ const live = disk.length ? [] : await liveMetadataMatches(selector, scope, localMachine, deps);
4931
+ if (disk.length > 0)
4932
+ indexed = resolveIndexedMetadataRows(disk, selector, scope);
4933
+ else if (live.length > 0)
4934
+ indexed = live;
4935
+ else if (scope.agent !== 'claude' && scope.agent !== 'codex') {
4936
+ const { scanSessionsIncremental, waitForScanToSettle } = await import('../lib/session/discover.js');
4937
+ const { claimed } = await scanSessionsIncremental({ agent: scope.agent });
4938
+ if (!claimed) {
4939
+ if (!await waitForScanToSettle())
4940
+ throw new Error('Session lookup is incomplete: another index scan is still running. Retry when it finishes.');
4941
+ const retry = await scanSessionsIncremental({ agent: scope.agent });
4942
+ if (!retry.claimed)
4943
+ throw new Error('Session lookup is incomplete: the session index is busy. Retry when the scan finishes.');
4944
+ }
4945
+ indexed = resolveIndexedMetadataRows(indexedRowsForSelector(selector, scope), selector, scope);
4946
+ }
4947
+ }
4948
+ const hydrated = [];
4949
+ for (const session of indexed) {
4950
+ hydrated.push(await hydrateSessionTranscript({ ...session, machine: session.machine || localMachine }));
4951
+ }
4952
+ return hydrated;
4926
4953
  }
4927
4954
  /**
4928
4955
  * Resolve an id-shaped selector against the local live-session registry
package/dist/index.js CHANGED
File without changes
@@ -0,0 +1,54 @@
1
+ import type { AgentId, Meta } from '../types.js';
2
+ import { type RotateCandidate } from './rotate.js';
3
+ export type ResolvedLaunchAccountKind = 'native' | 'provider' | 'legacy-native';
4
+ /** Safe account identity that may be passed between launch consumers. */
5
+ export interface ResolvedLaunchAccount {
6
+ kind: ResolvedLaunchAccountKind;
7
+ /** Stable registry id, or the durable identity key for an unmigrated login. */
8
+ id: string;
9
+ /** User-facing account name, falling back to the durable identity label. */
10
+ name: string;
11
+ /** Selector another device may resolve against its own registry. */
12
+ selector: string;
13
+ /** Stable anti-collision/failover key; never derived from the binary label. */
14
+ key: string;
15
+ }
16
+ /**
17
+ * One fully-resolved spawn attempt. `env` may contain credential material and
18
+ * `execHome` is a device-local path, so this type is confined to the local exec
19
+ * boundary. Events and remote callers use {@link ResolvedLaunchAccount} only.
20
+ */
21
+ export interface ResolvedLocalAccountLaunch {
22
+ agent: AgentId;
23
+ executableVersion: string;
24
+ account: ResolvedLaunchAccount | null;
25
+ execHome?: string;
26
+ /** Compatibility label for an explicitly recorded, unmigrated home. */
27
+ configVersion?: string;
28
+ env: Record<string, string>;
29
+ signedIn: boolean | null;
30
+ email: string | null;
31
+ }
32
+ export interface ResolveLocalAccountLaunchOptions {
33
+ agent: AgentId;
34
+ /** Selects the executable only. It never chooses account state. */
35
+ executableVersion: string;
36
+ /** Exact account candidate selected by strategy/picker/readiness. */
37
+ candidate?: RotateCandidate;
38
+ /** Explicit account name/id (`--account`, `#name`, or a consumer intent). */
39
+ selector?: string;
40
+ /** Resolve the harness default when no explicit selector/candidate exists. */
41
+ useDefault?: boolean;
42
+ provider?: string;
43
+ target?: string;
44
+ meta?: Pick<Meta, 'accounts' | 'deviceAccounts'>;
45
+ }
46
+ /**
47
+ * Resolve one account-specific attempt at the local spawn boundary.
48
+ *
49
+ * Native selections resolve the registered slot and harness-specific home;
50
+ * provider selections materialize their credential env exactly here. An
51
+ * unmigrated version-home candidate remains a compatibility case, but it is
52
+ * never rediscovered by scanning homes for identity.
53
+ */
54
+ export declare function resolveLocalAccountLaunch(options: ResolveLocalAccountLaunchOptions): Promise<ResolvedLocalAccountLaunch>;