@phnx-labs/agents-cli 1.22.104 → 1.22.106

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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/run-device-picker.d.ts +58 -0
  8. package/dist/commands/run-device-picker.js +222 -0
  9. package/dist/commands/sessions-resume.d.ts +4 -0
  10. package/dist/commands/sessions-resume.js +132 -49
  11. package/dist/commands/sessions.js +32 -5
  12. package/dist/commands/setup-computer.js +2 -2
  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/models.d.ts +1 -1
  28. package/dist/lib/models.js +4 -4
  29. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  30. package/dist/lib/session/actor-sidecar.js +3 -0
  31. package/dist/lib/session/claude-accounts.d.ts +12 -73
  32. package/dist/lib/session/claude-accounts.js +32 -70
  33. package/dist/lib/session/db.d.ts +1 -1
  34. package/dist/lib/session/db.js +18 -5
  35. package/dist/lib/session/discover.d.ts +4 -0
  36. package/dist/lib/session/discover.js +116 -15
  37. package/dist/lib/session/recovery.d.ts +30 -34
  38. package/dist/lib/session/recovery.js +212 -76
  39. package/dist/lib/session/types.d.ts +2 -0
  40. package/dist/lib/teams/placement-probe.js +1 -1
  41. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  42. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  43. package/dist/session-tracker/dist/hook.sh +191 -0
  44. package/dist/session-tracker/dist/index.d.ts +19 -0
  45. package/dist/session-tracker/dist/index.js +67 -0
  46. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  47. package/dist/session-tracker/dist/install-hook.js +245 -0
  48. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  49. package/dist/session-tracker/dist/prune-state.js +7 -0
  50. package/dist/session-tracker/dist/reader.d.ts +7 -0
  51. package/dist/session-tracker/dist/reader.js +151 -0
  52. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  53. package/dist/session-tracker/dist/state-file.js +119 -0
  54. package/dist/session-tracker/dist/types.d.ts +32 -0
  55. package/dist/session-tracker/dist/types.js +1 -0
  56. package/dist/session-tracker/dist/writer.d.ts +12 -0
  57. package/dist/session-tracker/dist/writer.js +27 -0
  58. package/package.json +1 -1
@@ -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
@@ -43,8 +43,8 @@ export async function runComputerWizard() {
43
43
  catch (error) {
44
44
  if (!(error instanceof ComputerClientError) || error.code !== 'COMPUTER_BIN_MISSING')
45
45
  throw error;
46
- console.log('Installing @phnx-labs/computer-cli@0.1.1…');
47
- const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.1'], { stdio: 'inherit' });
46
+ console.log('Installing @phnx-labs/computer-cli@0.1.2…');
47
+ const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.2'], { stdio: 'inherit' });
48
48
  if (installed.status !== 0)
49
49
  return false;
50
50
  try {
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>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Canonical target-local account launch resolution (PHNX-3940).
3
+ *
4
+ * Callers may carry a selector or a {@link RotateCandidate} across a routing
5
+ * boundary. This module resolves that intent on the machine that will spawn the
6
+ * harness, where account slots and credential stores actually live. The result
7
+ * is deliberately local-only: `execHome` and `env` must never cross SSH or be
8
+ * written to events.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import { resolveSpawnAccount } from '../account-registry.js';
12
+ import { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } from '../exec-account-home.js';
13
+ import { getVersionHomePath } from '../installations/versions.js';
14
+ import { readMeta } from '../state.js';
15
+ import { candidateAccountKey } from './rotate.js';
16
+ function launchAccountFromSpawn(account) {
17
+ return {
18
+ kind: account.kind,
19
+ id: account.id,
20
+ name: account.name,
21
+ selector: account.name,
22
+ key: `${account.kind}:${account.id}`,
23
+ };
24
+ }
25
+ function candidateSelector(candidate) {
26
+ return candidate.nativeAccount ?? candidate.providerAccount;
27
+ }
28
+ function legacyAccount(candidate) {
29
+ const key = candidateAccountKey(candidate);
30
+ const name = candidate.accountLabel || candidate.email || key;
31
+ return {
32
+ kind: 'legacy-native',
33
+ id: candidate.accountKey ?? candidate.email ?? key,
34
+ name,
35
+ selector: candidate.email ?? candidate.accountKey ?? name,
36
+ key,
37
+ };
38
+ }
39
+ /**
40
+ * Resolve one account-specific attempt at the local spawn boundary.
41
+ *
42
+ * Native selections resolve the registered slot and harness-specific home;
43
+ * provider selections materialize their credential env exactly here. An
44
+ * unmigrated version-home candidate remains a compatibility case, but it is
45
+ * never rediscovered by scanning homes for identity.
46
+ */
47
+ export async function resolveLocalAccountLaunch(options) {
48
+ const meta = options.meta ?? readMeta();
49
+ const selected = options.candidate;
50
+ const selector = options.selector ?? (selected ? candidateSelector(selected) : undefined);
51
+ // Compatibility for a candidate already discovered in an unmigrated home.
52
+ // The candidate is the identity decision; the label only locates that exact
53
+ // local config home and never participates in account comparison.
54
+ if (selected && !selector) {
55
+ const execHome = getVersionHomePath(options.agent, selected.version);
56
+ if (!fs.existsSync(execHome)) {
57
+ throw new Error(`${selected.accountLabel || options.agent} has no local account home at ${execHome}.`);
58
+ }
59
+ return {
60
+ agent: options.agent,
61
+ executableVersion: options.executableVersion,
62
+ account: legacyAccount(selected),
63
+ execHome,
64
+ configVersion: selected.version,
65
+ env: {},
66
+ signedIn: selected.signedIn,
67
+ email: selected.email,
68
+ };
69
+ }
70
+ const spawnAccount = resolveSpawnAccount(selector, options.agent, options.executableVersion, meta, {
71
+ useDefault: options.useDefault,
72
+ provider: options.provider,
73
+ target: options.target,
74
+ });
75
+ if (!spawnAccount) {
76
+ return {
77
+ agent: options.agent,
78
+ executableVersion: options.executableVersion,
79
+ account: null,
80
+ env: {},
81
+ signedIn: selected?.signedIn ?? null,
82
+ email: selected?.email ?? null,
83
+ };
84
+ }
85
+ const account = launchAccountFromSpawn(spawnAccount);
86
+ if (spawnAccount.kind === 'provider') {
87
+ return {
88
+ agent: options.agent,
89
+ executableVersion: options.executableVersion,
90
+ account,
91
+ env: spawnAccount.env,
92
+ signedIn: selected?.signedIn ?? true,
93
+ email: selected?.email ?? null,
94
+ };
95
+ }
96
+ if (isSymlinkAdoptedHarness(options.agent)) {
97
+ const defaultName = meta.accounts?.defaults?.[options.agent];
98
+ if (spawnAccount.name !== defaultName) {
99
+ throw new Error(symlinkAdoptedAccountError(options.agent, spawnAccount.name, defaultName));
100
+ }
101
+ }
102
+ const home = await resolveNativeSpawnHome(options.agent, spawnAccount, meta);
103
+ if (isSymlinkAdoptedHarness(options.agent)
104
+ && !adoptedConfigPointsAtHome(options.agent, home.execHome)) {
105
+ throw new Error(adoptedSymlinkMismatchError(options.agent, spawnAccount.name, home.execHome));
106
+ }
107
+ return {
108
+ agent: options.agent,
109
+ executableVersion: options.executableVersion,
110
+ account,
111
+ execHome: home.execHome,
112
+ configVersion: home.source === 'legacy-home' ? home.label : undefined,
113
+ env: durableSlotEnv(options.agent, spawnAccount, home, meta),
114
+ signedIn: selected?.signedIn ?? null,
115
+ email: selected?.email ?? null,
116
+ };
117
+ }
@@ -19,7 +19,7 @@ function localRegistryRecords() {
19
19
  try {
20
20
  return Object.values(readAccountRegistry().accounts)
21
21
  .filter((a) => hasKeychainTokenSync(a.secretRef))
22
- .map((a) => ({ name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
22
+ .map((a) => ({ id: a.id, name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
23
23
  }
24
24
  catch (err) {
25
25
  if (isSecretsTransportError(err))
@@ -66,6 +66,7 @@ export function foldRegistryCandidates(agent, inputs) {
66
66
  authVerdict: null,
67
67
  lastActive: null,
68
68
  providerAccount: r.name,
69
+ providerAccountId: r.id,
69
70
  }));
70
71
  return [...native, ...extra];
71
72
  }
@@ -13,6 +13,7 @@ import type { AccountAuthKind } from '../account-provider-registry.js';
13
13
  */
14
14
  /** A provider account record as stored in the registry (identity captured separately). */
15
15
  export interface RegistryAccountRecord {
16
+ id?: string;
16
17
  name: string;
17
18
  provider: string;
18
19
  auth: AccountAuthKind;
@@ -29,6 +30,7 @@ export interface RegistryAccountRecord {
29
30
  }
30
31
  /** A registry account eligible to run one harness, ready to map to a candidate. */
31
32
  export interface RegistryAccountInput {
33
+ id?: string;
32
34
  /** Agent-scoped key so `(claude, X)` and `(codex, X)` stay distinct. */
33
35
  accountKey: string;
34
36
  email: string | null;
@@ -22,6 +22,7 @@ export function registryPoolCandidates(records, agent) {
22
22
  if (!providerAuthenticatesHarness(r.provider, r.auth, agent))
23
23
  continue;
24
24
  out.push({
25
+ id: r.id,
25
26
  accountKey: `${agent}:name=${r.name}`,
26
27
  email: null,
27
28
  name: r.name,