@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.
- package/CHANGELOG.md +18 -0
- package/README.md +19 -1
- package/dist/browser.js +0 -0
- package/dist/commands/exec.js +135 -233
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/commands/setup-computer.js +2 -2
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- 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,
|
|
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 (
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4921
|
-
if (
|
|
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
|
|
4925
|
-
|
|
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.
|
|
47
|
-
const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.
|
|
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;
|