@phnx-labs/agents-cli 1.22.107 → 1.22.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/dist/bootstrap.js +3 -2
- package/dist/commands/browser-sessions-picker.js +2 -1
- package/dist/commands/computer-sessions-picker.js +2 -1
- package/dist/commands/cost.js +2 -1
- package/dist/commands/daemon.js +45 -40
- package/dist/commands/doctor.js +7 -0
- package/dist/commands/exec.js +4 -2
- package/dist/commands/fork.js +6 -4
- package/dist/commands/logs.js +2 -1
- package/dist/commands/monitors.js +15 -2
- package/dist/commands/routines.js +46 -6
- package/dist/commands/sessions-backfill.d.ts +30 -0
- package/dist/commands/sessions-backfill.js +72 -0
- package/dist/commands/sessions-backup-setup.d.ts +12 -0
- package/dist/commands/sessions-backup-setup.js +65 -0
- package/dist/commands/sessions-inject.d.ts +3 -3
- package/dist/commands/sessions-inject.js +5 -11
- package/dist/commands/sessions-picker.js +10 -7
- package/dist/commands/sessions-resume.d.ts +17 -2
- package/dist/commands/sessions-resume.js +88 -12
- package/dist/commands/sessions.js +46 -24
- package/dist/commands/share.js +15 -2
- package/dist/commands/sync.js +30 -1
- package/dist/commands/utils.d.ts +9 -0
- package/dist/commands/utils.js +18 -0
- package/dist/commands/watchdog.js +4 -1
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +17 -1
- package/dist/lib/accounting/usage-sync.d.ts +22 -0
- package/dist/lib/accounting/usage-sync.js +70 -6
- package/dist/lib/accounting/usage.d.ts +32 -0
- package/dist/lib/accounting/usage.js +37 -2
- package/dist/lib/accounts/slots.js +7 -0
- package/dist/lib/agent-spec/agents.d.ts +60 -8
- package/dist/lib/agent-spec/agents.js +118 -45
- package/dist/lib/auth-health.js +19 -0
- package/dist/lib/claude-statusline.js +5 -0
- package/dist/lib/computer/sessions-list.js +2 -1
- package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
- package/dist/lib/daemon/auth-sync-service.js +2 -2
- package/dist/lib/daemon/daemon.d.ts +34 -1
- package/dist/lib/daemon/daemon.js +70 -9
- package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
- package/dist/lib/daemon/leaked-daemons.js +180 -0
- package/dist/lib/daemon/session-title-service.d.ts +41 -0
- package/dist/lib/daemon/session-title-service.js +76 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
- package/dist/lib/daemon/usage-sync-service.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/daemon-ticks.js +6 -0
- package/dist/lib/devices/doctor-findings.d.ts +7 -1
- package/dist/lib/devices/doctor-findings.js +32 -1
- package/dist/lib/fleet-shared-state.d.ts +2 -0
- package/dist/lib/hooks/install.js +78 -64
- package/dist/lib/mailbox-target.js +2 -1
- package/dist/lib/models.js +76 -5
- package/dist/lib/session/active.d.ts +93 -16
- package/dist/lib/session/active.js +80 -14
- package/dist/lib/session/cloud.js +3 -1
- package/dist/lib/session/db.d.ts +44 -1
- package/dist/lib/session/db.js +122 -17
- package/dist/lib/session/fork.d.ts +10 -2
- package/dist/lib/session/fork.js +11 -2
- package/dist/lib/session/live-metadata.js +6 -0
- package/dist/lib/session/mirror.d.ts +3 -2
- package/dist/lib/session/mirror.js +5 -2
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +136 -2
- package/dist/lib/session/recovery.d.ts +9 -1
- package/dist/lib/session/recovery.js +14 -4
- package/dist/lib/session/remote/watch.js +18 -8
- package/dist/lib/session/title.d.ts +256 -0
- package/dist/lib/session/title.js +312 -0
- package/dist/lib/session/tool-calls.d.ts +1 -1
- package/dist/lib/session/tool-calls.js +40 -5
- package/dist/lib/session/tool-index.d.ts +5 -0
- package/dist/lib/session/tool-index.js +1 -0
- package/dist/lib/session/types.d.ts +11 -0
- package/dist/lib/share/backend.d.ts +23 -0
- package/dist/lib/share/backend.js +24 -0
- package/dist/lib/share/provision.d.ts +12 -0
- package/dist/lib/share/provision.js +30 -0
- package/dist/lib/share/worker-template.js +273 -0
- package/dist/lib/startup/root-command.d.ts +2 -0
- package/dist/lib/startup/root-command.js +22 -0
- package/dist/lib/terminal/engine.js +13 -1
- package/dist/lib/traces/sync.js +1 -0
- package/dist/lib/usage-refresh.d.ts +58 -7
- package/dist/lib/usage-refresh.js +145 -23
- package/dist/lib/watchdog/runner.d.ts +3 -0
- package/dist/lib/watchdog/runner.js +1 -0
- package/dist/session-tracker/dist/install-hook.js +4 -4
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
|
7
7
|
import { ensureToolIndex, readToolIndexCoverage } from '../lib/session/tool-index.js';
|
|
8
8
|
import { NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
9
9
|
import { backfillResourceUsage } from '../lib/session/db.js';
|
|
10
|
+
import { runSessionTitleTick, SESSION_TITLE_MAX_PER_TICK } from '../lib/session/title.js';
|
|
10
11
|
const BACKFILL_REMOTE_TIMEOUT_MS = 10 * 60_000;
|
|
11
12
|
function parseAgent(value) {
|
|
12
13
|
if (!value)
|
|
@@ -181,6 +182,31 @@ export async function runResourceBackfill(options) {
|
|
|
181
182
|
...result,
|
|
182
183
|
};
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Generate session headlines NOW instead of waiting for the daemon's sweep — the
|
|
187
|
+
* explicit-refresh half of PHNX-3797. Same code path the `session-title` service
|
|
188
|
+
* ticks, so there is one generator: this only changes when it runs, how many it
|
|
189
|
+
* does, and (with `--refresh`) whether an already-current title is regenerated.
|
|
190
|
+
*/
|
|
191
|
+
export async function runTitlesBackfill(options) {
|
|
192
|
+
const parsedLimit = options.limit ? Number.parseInt(options.limit, 10) : undefined;
|
|
193
|
+
if (options.limit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 1)) {
|
|
194
|
+
throw new Error(`--limit must be a positive integer (got "${options.limit}")`);
|
|
195
|
+
}
|
|
196
|
+
const result = await runSessionTitleTick({
|
|
197
|
+
...(options.session ? { id: options.session } : {}),
|
|
198
|
+
...(options.run ? { run: options.run } : {}),
|
|
199
|
+
limit: parsedLimit ?? (options.session ? 1 : SESSION_TITLE_MAX_PER_TICK * 5),
|
|
200
|
+
force: Boolean(options.refresh),
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
schemaVersion: 1,
|
|
204
|
+
kind: 'titles-backfill',
|
|
205
|
+
generatedAt: new Date().toISOString(),
|
|
206
|
+
machine: machineId(),
|
|
207
|
+
...result,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
184
210
|
export function registerSessionsBackfillCommand(sessionsCmd) {
|
|
185
211
|
const backfill = sessionsCmd.command('backfill').description('Populate derived session data explicitly.');
|
|
186
212
|
const tools = backfill.command('tools').description('Parse historical tool calls once into the local SQLite index.');
|
|
@@ -265,4 +291,50 @@ export function registerSessionsBackfillCommand(sessionsCmd) {
|
|
|
265
291
|
process.exitCode = 1;
|
|
266
292
|
}
|
|
267
293
|
});
|
|
294
|
+
const titles = backfill
|
|
295
|
+
.command('titles')
|
|
296
|
+
.description('Generate the session-row headline (a short technical title) now, instead of waiting for the daemon.')
|
|
297
|
+
.option('--session <id>', 'Title one session (full or short id), even if it is old or already titled')
|
|
298
|
+
.option('--limit <n>', 'Maximum titles to generate in this run')
|
|
299
|
+
.option('--refresh', 'Regenerate even when the stored title still matches the session\'s first user message')
|
|
300
|
+
.option('--json', 'Emit the machine-readable result');
|
|
301
|
+
setHelpSections(titles, {
|
|
302
|
+
examples: `
|
|
303
|
+
# Catch this machine up now (the daemon otherwise does a couple every 2 min)
|
|
304
|
+
agents sessions backfill titles
|
|
305
|
+
|
|
306
|
+
# Re-title one session after correcting its first message
|
|
307
|
+
agents sessions backfill titles --session 6fc1db18 --refresh
|
|
308
|
+
|
|
309
|
+
# Machine-readable
|
|
310
|
+
agents sessions backfill titles --limit 20 --json
|
|
311
|
+
`,
|
|
312
|
+
notes: `
|
|
313
|
+
- The headline ladder is: \`/rename\` label > this generated title > the user's first message. It is never the agent's latest turn.
|
|
314
|
+
- One cheap-model call per session (\`--model cheap\`, read-only plan mode), generated ONCE and persisted; a session whose first message has not changed is a pure cache hit.
|
|
315
|
+
- Local-only: each box titles its own sessions and publishes them to the fleet via the session mirror, so a peer's rows already carry their titles.
|
|
316
|
+
- Best-effort. With no signed-in harness nothing is generated and rows keep showing the user's own words.
|
|
317
|
+
`,
|
|
318
|
+
});
|
|
319
|
+
titles.action(async (_options, command) => {
|
|
320
|
+
const options = command.optsWithGlobals();
|
|
321
|
+
try {
|
|
322
|
+
const envelope = await runTitlesBackfill(options);
|
|
323
|
+
if (options.json)
|
|
324
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
|
|
325
|
+
else {
|
|
326
|
+
console.log(`${envelope.machine}: generated ${envelope.generated.toLocaleString()} title${envelope.generated === 1 ? '' : 's'}; ` +
|
|
327
|
+
`${envelope.cached.toLocaleString()} already current, ${envelope.scanned.toLocaleString()} scanned.`);
|
|
328
|
+
for (const { id, title } of envelope.titles)
|
|
329
|
+
console.log(` ${chalk.cyan(id.slice(0, 8))} ${title}`);
|
|
330
|
+
if (envelope.failed > 0) {
|
|
331
|
+
console.log(chalk.yellow(`${envelope.failed.toLocaleString()} session${envelope.failed === 1 ? '' : 's'} produced no usable title; rerun to retry.`));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
268
340
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
interface BackupSetupOptions {
|
|
3
|
+
bundle: string;
|
|
4
|
+
worker: string;
|
|
5
|
+
bucket: string;
|
|
6
|
+
account?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
domain: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function handleSessionsBackupSetup(opts: BackupSetupOptions): Promise<void>;
|
|
11
|
+
export declare function registerSessionsBackupSetupCommand(sessionsCmd: Command): void;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `agents sessions backup-setup` — the OPERATOR command that provisions the
|
|
2
|
+
// managed session-backup endpoint (`sessions.agents-cli.sh`): the Cloudflare
|
|
3
|
+
// Worker + R2 bucket a signed-in user's `sessions export --to-r2` talks to with
|
|
4
|
+
// NO `r2.backups` bucket of their own. It is the deploy producer for that
|
|
5
|
+
// endpoint — first-party infrastructure, not a per-user step. The zero-knowledge
|
|
6
|
+
// `--byo` backup path uses the user's own R2 bucket directly and never touches
|
|
7
|
+
// this Worker, so there is nothing here for an ordinary user to run.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors `agents traces setup`: the same `readCloudflareCreds` bundle plumbing,
|
|
10
|
+
// the same idempotent `deployWorker`/`createBucket` primitives. Provisioning is
|
|
11
|
+
// idempotent — re-running redeploys the current Worker template in place.
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { DEFAULT_CF_BUNDLE, readCloudflareCreds } from '../lib/share/config.js';
|
|
14
|
+
import { PHOENIX_ID_BASE } from '../lib/identity/client.js';
|
|
15
|
+
import { provisionSessions } from '../lib/session/sync/provision.js';
|
|
16
|
+
import { DEFAULT_SESSIONS_BUCKET_NAME, DEFAULT_SESSIONS_DOMAIN, DEFAULT_SESSIONS_WORKER_NAME, } from '../lib/session/sync/managed-config.js';
|
|
17
|
+
import { setHelpSections } from '../lib/help.js';
|
|
18
|
+
export async function handleSessionsBackupSetup(opts) {
|
|
19
|
+
const { input } = await import('@inquirer/prompts');
|
|
20
|
+
const { apiToken, accountId: bundledAccountId } = readCloudflareCreds(opts.bundle, {
|
|
21
|
+
apiToken: opts.token,
|
|
22
|
+
accountId: opts.account,
|
|
23
|
+
});
|
|
24
|
+
const accountId = opts.account ?? bundledAccountId ?? await input({ message: 'Cloudflare account id' });
|
|
25
|
+
if (!accountId)
|
|
26
|
+
throw new Error('A Cloudflare account id is required.');
|
|
27
|
+
const result = await provisionSessions({
|
|
28
|
+
apiToken,
|
|
29
|
+
accountId,
|
|
30
|
+
workerName: opts.worker,
|
|
31
|
+
bucketName: opts.bucket,
|
|
32
|
+
domain: opts.domain,
|
|
33
|
+
phoenixIdBase: PHOENIX_ID_BASE,
|
|
34
|
+
});
|
|
35
|
+
console.log(chalk.green(`Managed session-backup endpoint ready → ${chalk.bold(result.baseUrl)}`));
|
|
36
|
+
console.log(chalk.dim('Signed-in users now back up with `agents sessions export --to-r2` — no r2.backups bucket.'));
|
|
37
|
+
}
|
|
38
|
+
const BACKUP_SETUP_EXAMPLES = `
|
|
39
|
+
$ agents secrets exec cloudflare -- agents sessions backup-setup
|
|
40
|
+
Provision the managed session-backup Worker + R2 bucket (creds from the bundle).
|
|
41
|
+
|
|
42
|
+
$ agents sessions backup-setup --account <id> --domain sessions.example.com
|
|
43
|
+
Provision against a private account service and custom domain.
|
|
44
|
+
`.trimStart();
|
|
45
|
+
export function registerSessionsBackupSetupCommand(sessionsCmd) {
|
|
46
|
+
const cmd = sessionsCmd
|
|
47
|
+
.command('backup-setup')
|
|
48
|
+
.description('(operator) Provision the managed session-backup Worker + R2 bucket — NOT a per-user step; signing in with `agents auth login` backs sessions up with zero setup')
|
|
49
|
+
.option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
|
|
50
|
+
.option('--worker <name>', 'Worker name', DEFAULT_SESSIONS_WORKER_NAME)
|
|
51
|
+
.option('--bucket <name>', 'R2 bucket name', DEFAULT_SESSIONS_BUCKET_NAME)
|
|
52
|
+
.option('--account <id>', 'Cloudflare account id (else read from the bundle / prompt)')
|
|
53
|
+
.option('--token <token>', 'Cloudflare API token (else read from the --bundle)')
|
|
54
|
+
.option('--domain <host>', 'custom domain to map', DEFAULT_SESSIONS_DOMAIN)
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
try {
|
|
57
|
+
await handleSessionsBackupSetup(opts);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error(chalk.red(err.message));
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
setHelpSections(cmd, { examples: BACKUP_SETUP_EXAMPLES });
|
|
65
|
+
}
|
|
@@ -43,9 +43,9 @@ export declare function matchInjectSelector(session: ActiveSession, token: strin
|
|
|
43
43
|
* merges the parent `sessions` command's variadic `-D, --device <target...>` over
|
|
44
44
|
* this subcommand's scalar `--device`, so a single `--device box` arrives as
|
|
45
45
|
* `['box']` — which flowed straight into `sshExec` and crashed on
|
|
46
|
-
* `host.startsWith` (PHNX-3688).
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* `host.startsWith` (PHNX-3688). Delegates to the shared
|
|
47
|
+
* {@link normalizeSingleDeviceOption} (also used by `sessions resume`,
|
|
48
|
+
* PHNX-3940) rather than re-implementing the array/scalar coercion here.
|
|
49
49
|
*/
|
|
50
50
|
export declare function normalizeInjectDevice(value: string | string[] | undefined): string | undefined;
|
|
51
51
|
/**
|
|
@@ -19,6 +19,7 @@ import { sshExec, shellQuote } from '../lib/ssh-exec.js';
|
|
|
19
19
|
import { resolveHost } from '../lib/hosts/registry.js';
|
|
20
20
|
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
21
21
|
import { setHelpSections } from '../lib/help.js';
|
|
22
|
+
import { normalizeSingleDeviceOption } from './utils.js';
|
|
22
23
|
/**
|
|
23
24
|
* Whether an active session is the one `sessions inject <token>` means. Matches
|
|
24
25
|
* a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
|
|
@@ -47,19 +48,12 @@ export function matchInjectSelector(session, token) {
|
|
|
47
48
|
* merges the parent `sessions` command's variadic `-D, --device <target...>` over
|
|
48
49
|
* this subcommand's scalar `--device`, so a single `--device box` arrives as
|
|
49
50
|
* `['box']` — which flowed straight into `sshExec` and crashed on
|
|
50
|
-
* `host.startsWith` (PHNX-3688).
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* `host.startsWith` (PHNX-3688). Delegates to the shared
|
|
52
|
+
* {@link normalizeSingleDeviceOption} (also used by `sessions resume`,
|
|
53
|
+
* PHNX-3940) rather than re-implementing the array/scalar coercion here.
|
|
53
54
|
*/
|
|
54
55
|
export function normalizeInjectDevice(value) {
|
|
55
|
-
|
|
56
|
-
const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
|
|
57
|
-
if (hosts.length === 0)
|
|
58
|
-
return undefined;
|
|
59
|
-
if (hosts.length > 1) {
|
|
60
|
-
throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
|
|
61
|
-
}
|
|
62
|
-
return hosts[0];
|
|
56
|
+
return normalizeSingleDeviceOption(value, 'sessions inject');
|
|
63
57
|
}
|
|
64
58
|
/**
|
|
65
59
|
* The `agents sessions inject` argv to re-run ON a device (its tmux panes live
|
|
@@ -106,6 +106,7 @@ function sanitizeMeta(s) {
|
|
|
106
106
|
topic: clean(s.topic),
|
|
107
107
|
firstUserMessage: clean(s.firstUserMessage),
|
|
108
108
|
label: clean(s.label),
|
|
109
|
+
generatedTitle: clean(s.generatedTitle),
|
|
109
110
|
ticketId: clean(s.ticketId),
|
|
110
111
|
prUrl: clean(s.prUrl),
|
|
111
112
|
// A remote row's meta is peer-supplied JSON that parseRemoteList hands over
|
|
@@ -303,6 +304,7 @@ function previewCacheKey(session, remote) {
|
|
|
303
304
|
// independently (label/ticket/PR/live scanner enrichment) also rides the key.
|
|
304
305
|
return JSON.stringify([
|
|
305
306
|
remote ?? '', remoteDigestState, session.id, fileStamp, session.lastActivity, session.label,
|
|
307
|
+
session.generatedTitle,
|
|
306
308
|
session.topic, session.ticketId, session.prUrl, session.messageCount,
|
|
307
309
|
session.tokenCount, session.model, session.todos, session.plan,
|
|
308
310
|
session.recentDirectoriesTouched, session.skillsUsed,
|
|
@@ -489,13 +491,14 @@ function formatHeader(session, events) {
|
|
|
489
491
|
line4.push(chalk.blue(linkUrl(session.prUrl, label)));
|
|
490
492
|
}
|
|
491
493
|
// Lead with the session's human title: `session.label` — an agent-generated
|
|
492
|
-
// name / `/rename`, else the `--name` launch handle
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
|
|
494
|
+
// name / `/rename`, else the `--name` launch handle — else the daemon-generated
|
|
495
|
+
// title (PHNX-3797), which is a short technical NAME, not a restatement of the
|
|
496
|
+
// prompt. NOT `session.topic`: the topic is the derived first-prompt, already
|
|
497
|
+
// shown on the `Prompt:` line, so using it here too would print the same text
|
|
498
|
+
// twice. A session with neither keeps that `Prompt:` line as its topic
|
|
499
|
+
// indicator and simply leads with the agent line. Wrapped to the pane (the
|
|
500
|
+
// header sits at column 0, full terminal width).
|
|
501
|
+
const title = (session.label || session.generatedTitle || '').trim();
|
|
499
502
|
const titleLines = title
|
|
500
503
|
? wrapToWidth(title, terminalWidth()).map(l => chalk.bold.white(l))
|
|
501
504
|
: [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Command } from 'commander';
|
|
2
2
|
import { type SessionMeta } from '../lib/session/types.js';
|
|
3
|
-
import { type Backend, type EngineContext, type Packing } from '../lib/terminal/index.js';
|
|
3
|
+
import { type Backend, type SurfaceItem, type EngineContext, type Packing } from '../lib/terminal/index.js';
|
|
4
4
|
import { type StrictResumeOptions } from './resume.js';
|
|
5
5
|
/** Opening more than this many live sessions at once asks for confirmation first. */
|
|
6
6
|
export declare const CONFIRM_THRESHOLD = 5;
|
|
@@ -24,6 +24,8 @@ export interface ResumeOptions extends StrictResumeOptions {
|
|
|
24
24
|
runArgs?: string[];
|
|
25
25
|
}
|
|
26
26
|
export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
|
|
27
|
+
/** Keep explicit parent options without replacing resume's own defaults. */
|
|
28
|
+
export declare function resolveResumeOptions(cmd: Command, local: ResumeOptions): ResumeOptions;
|
|
27
29
|
export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
|
|
28
30
|
/** Preserve run options and lifecycle intent when the picker opens its selected rows. */
|
|
29
31
|
export declare function buildSelectedResumeArgs(id: string, prompt: string | undefined, options: ResumeOptions): string[];
|
|
@@ -33,6 +35,15 @@ export declare function isDirectResumeSelector(query: string): boolean;
|
|
|
33
35
|
/** Re-enter through sessions resume so fleet routing and harness policy
|
|
34
36
|
* stay centralized. The child inherits this terminal for a real interactive resume. */
|
|
35
37
|
export declare function resumeSelectorInPlace(selector: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Local preflight cannot inspect a peer's files or index. Leave peer validation
|
|
40
|
+
* to the existing origin-device recovery hop. --here without a remote surface
|
|
41
|
+
* opts into local recovery and therefore uses the local transcript guard.
|
|
42
|
+
*/
|
|
43
|
+
export declare function partitionResumableSelections(chosen: SessionMeta[], options?: Pick<ResumeOptions, 'here' | 'device'>): {
|
|
44
|
+
resumable: SessionMeta[];
|
|
45
|
+
skipped: SessionMeta[];
|
|
46
|
+
};
|
|
36
47
|
/** Direct identities use focus as the lifecycle dispatcher: it rechecks the
|
|
37
48
|
* live fleet, attaches a healthy pane, and falls through to `agents resume`
|
|
38
49
|
* only when the process is no longer attachable. */
|
|
@@ -42,6 +53,10 @@ export declare function resumeUsesLifecycleDispatch(query: string | undefined, p
|
|
|
42
53
|
export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): Promise<void>;
|
|
43
54
|
export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): string[];
|
|
44
55
|
export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
|
|
56
|
+
/** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
|
|
57
|
+
export declare function buildSelectedResumeSurface(session: SessionMeta, prompt: string | undefined, options: ResumeOptions): SurfaceItem & {
|
|
58
|
+
session: SessionMeta;
|
|
59
|
+
};
|
|
45
60
|
export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
|
|
46
61
|
/**
|
|
47
62
|
* Decide which backend to launch into. Returns a concrete backend, `'inplace'`
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
|
+
import { Option } from 'commander';
|
|
15
16
|
import { isAgentTmuxAlias } from '../lib/session/types.js';
|
|
16
17
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
17
18
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
@@ -19,16 +20,18 @@ import { multiItemPicker, itemPicker } from '../lib/picker.js';
|
|
|
19
20
|
import { buildPreview } from './sessions-picker.js';
|
|
20
21
|
import { formatPickerLabel, pickerColumnsFor, resolveSessionMetadataValue, parseAgentFilter, } from './sessions.js';
|
|
21
22
|
import { sessionMatchesQuery } from './sessions-browser.js';
|
|
22
|
-
import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
|
|
23
|
-
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
23
|
+
import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, shellQuote, } from '../lib/terminal/index.js';
|
|
24
|
+
import { isInteractiveTerminal, isPromptCancelled, normalizeSingleDeviceOption } from './utils.js';
|
|
24
25
|
import { setHelpSections } from '../lib/help.js';
|
|
25
26
|
import { confirm } from '@inquirer/prompts';
|
|
26
27
|
import { spawn } from 'node:child_process';
|
|
27
28
|
import { looksLikeSessionId } from '../lib/session/discover.js';
|
|
28
29
|
import { machineId } from '../lib/session/sync/config.js';
|
|
29
|
-
import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
|
|
30
|
+
import { sessionOriginDevice, sessionRecoveryDestinationMatches, sessionRecoveryPeer, sessionTranscriptReadable } from '../lib/session/recovery.js';
|
|
30
31
|
import { buildResumeRemoteArgs, runStrictResume, wantsStrictResume } from './resume.js';
|
|
32
|
+
import { toRemotePortable } from '../lib/project-root.js';
|
|
31
33
|
import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
|
|
34
|
+
import { sessionHeadline } from '../lib/session/title.js';
|
|
32
35
|
/** Opening more than this many live sessions at once asks for confirmation first. */
|
|
33
36
|
export const CONFIRM_THRESHOLD = 5;
|
|
34
37
|
export function registerSessionsResumeCommand(sessionsCmd) {
|
|
@@ -44,6 +47,8 @@ export function registerSessionsResumeCommand(sessionsCmd) {
|
|
|
44
47
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
45
48
|
.option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
|
|
46
49
|
.option('-n, --limit <n>', 'Maximum number of sessions to load into the picker', '200')
|
|
50
|
+
.addOption(new Option('--resume-device <alias>').hideHelp()
|
|
51
|
+
.argParser((value, previous = []) => [...previous, value]))
|
|
47
52
|
.option('--device <alias>', 'Open on the session origin device over SSH; the device must match every selected session')
|
|
48
53
|
.option('--iterm', 'Force the iTerm backend')
|
|
49
54
|
.option('--ghostty', 'Force the Ghostty backend')
|
|
@@ -98,13 +103,39 @@ export function registerSessionsResumeCommand(sessionsCmd) {
|
|
|
98
103
|
- --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --device too).
|
|
99
104
|
- --device <alias> opens the terminal surface on that device only when it is the selected sessions' origin; recovery never migrates a session to another device.
|
|
100
105
|
- Recovery uses the installed harness and the conversation's account on its origin device. Context replay requires an explicit choice.
|
|
106
|
+
- Local picks without transcripts are skipped before opening a tab. Remote picks are validated on their origin device.
|
|
101
107
|
- agents run claude --resume opens this same picker; claude#work filters it with --account work.
|
|
102
108
|
`,
|
|
103
109
|
});
|
|
104
110
|
cmd.action(async (query, prompt, options) => {
|
|
105
|
-
await sessionsResumeAction(query, prompt, options);
|
|
111
|
+
await sessionsResumeAction(query, prompt, resolveResumeOptions(cmd, options));
|
|
106
112
|
});
|
|
107
113
|
}
|
|
114
|
+
/** Flag names `sessions resume` declares that also exist on its parent `sessions`
|
|
115
|
+
* command. Booleans and strings only — `device` collides too but needs its own
|
|
116
|
+
* array-merge handling below, so it is not in this list. */
|
|
117
|
+
const RESUME_PARENT_COLLISION_FLAGS = ['agent', 'all', 'teams', 'since', 'limit', 'local'];
|
|
118
|
+
/** Keep explicit parent options without replacing resume's own defaults. */
|
|
119
|
+
export function resolveResumeOptions(cmd, local) {
|
|
120
|
+
const parent = cmd.parent;
|
|
121
|
+
const { resumeDevice, ...resolved } = local;
|
|
122
|
+
const parentOpts = parent?.opts();
|
|
123
|
+
for (const key of RESUME_PARENT_COLLISION_FLAGS) {
|
|
124
|
+
if (parent?.getOptionValueSource(key) === 'cli') {
|
|
125
|
+
resolved[key] = parentOpts?.[key];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const rawDeviceTargets = [
|
|
129
|
+
...(resumeDevice ?? []),
|
|
130
|
+
...(local.device ? [local.device] : []),
|
|
131
|
+
...(parentOpts?.device ?? []),
|
|
132
|
+
...(parentOpts?.devices ?? []),
|
|
133
|
+
];
|
|
134
|
+
if (rawDeviceTargets.length > 0) {
|
|
135
|
+
resolved.device = normalizeSingleDeviceOption(rawDeviceTargets, 'sessions resume');
|
|
136
|
+
}
|
|
137
|
+
return resolved;
|
|
138
|
+
}
|
|
108
139
|
export async function sessionsResumeAction(query, prompt, options) {
|
|
109
140
|
if (options.attachOnly && (prompt !== undefined || options.agent || options.account || options.model || options.mode || options.interactive || options.headless || options.cwd || options.here)) {
|
|
110
141
|
throw new Error('--attach-only cannot be combined with --agent, a follow-up prompt, or options that change the running session. Pass the session ID without filters.');
|
|
@@ -232,12 +263,16 @@ export async function sessionsResumeAction(query, prompt, options) {
|
|
|
232
263
|
}
|
|
233
264
|
}
|
|
234
265
|
// 2. Route every selection through the owning device's recovery resolver.
|
|
235
|
-
const
|
|
236
|
-
for (const s of
|
|
237
|
-
|
|
238
|
-
const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
|
|
239
|
-
items.push({ session: s, cwd, command });
|
|
266
|
+
const { resumable, skipped } = partitionResumableSelections(chosen, options);
|
|
267
|
+
for (const s of skipped) {
|
|
268
|
+
console.log(chalk.yellow(`Skipping ${s.shortId} — nothing to resume (no transcript was written).`));
|
|
240
269
|
}
|
|
270
|
+
if (resumable.length === 0) {
|
|
271
|
+
console.error(chalk.red('Nothing to resume — no selected session has a transcript.'));
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const items = resumable.map((session) => buildSelectedResumeSurface(session, prompt, options));
|
|
241
276
|
// 3. Resolve the backend (and host).
|
|
242
277
|
const ctx = currentContext();
|
|
243
278
|
const backend = await resolveBackend(options, ctx, items.length);
|
|
@@ -279,7 +314,7 @@ export async function sessionsResumeAction(query, prompt, options) {
|
|
|
279
314
|
command: it.command,
|
|
280
315
|
agent: it.session.agent || undefined,
|
|
281
316
|
sessionId: it.session.id || undefined,
|
|
282
|
-
title: it.session
|
|
317
|
+
title: sessionHeadline(it.session),
|
|
283
318
|
})), { backend, host: options.device, packing });
|
|
284
319
|
let opened = 0;
|
|
285
320
|
results.forEach((r, i) => {
|
|
@@ -294,6 +329,8 @@ export async function sessionsResumeAction(query, prompt, options) {
|
|
|
294
329
|
}
|
|
295
330
|
});
|
|
296
331
|
console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
|
|
332
|
+
if (opened !== items.length)
|
|
333
|
+
process.exitCode = 1;
|
|
297
334
|
}
|
|
298
335
|
/** Preserve run options and lifecycle intent when the picker opens its selected rows. */
|
|
299
336
|
export function buildSelectedResumeArgs(id, prompt, options) {
|
|
@@ -307,6 +344,7 @@ export function buildSelectedResumeArgs(id, prompt, options) {
|
|
|
307
344
|
// The outer surface already placed this terminal on the selected device.
|
|
308
345
|
if (options.device) {
|
|
309
346
|
let remoteCwd;
|
|
347
|
+
let localCwd;
|
|
310
348
|
for (let i = 0; i < args.length && args[i] !== '--'; i++) {
|
|
311
349
|
if (args[i] === '--remote-cwd') {
|
|
312
350
|
remoteCwd = args[i + 1];
|
|
@@ -316,6 +354,14 @@ export function buildSelectedResumeArgs(id, prompt, options) {
|
|
|
316
354
|
remoteCwd = args[i].slice('--remote-cwd='.length);
|
|
317
355
|
args.splice(i--, 1);
|
|
318
356
|
}
|
|
357
|
+
else if (args[i] === '--cwd') {
|
|
358
|
+
localCwd = args[i + 1];
|
|
359
|
+
args.splice(i--, 2);
|
|
360
|
+
}
|
|
361
|
+
else if (args[i].startsWith('--cwd=')) {
|
|
362
|
+
localCwd = args[i].slice('--cwd='.length);
|
|
363
|
+
args.splice(i--, 1);
|
|
364
|
+
}
|
|
319
365
|
else if (['-D', '--device', '--host', '--where', '--on', '--computer'].includes(args[i])) {
|
|
320
366
|
args.splice(i, 2);
|
|
321
367
|
i--;
|
|
@@ -325,9 +371,10 @@ export function buildSelectedResumeArgs(id, prompt, options) {
|
|
|
325
371
|
i--;
|
|
326
372
|
}
|
|
327
373
|
}
|
|
328
|
-
|
|
374
|
+
const effectiveCwd = remoteCwd ?? options.cwd ?? (localCwd ? toRemotePortable(localCwd) : undefined);
|
|
375
|
+
if (effectiveCwd !== undefined) {
|
|
329
376
|
const end = args.indexOf('--');
|
|
330
|
-
args.splice(end < 0 ? args.length : end, 0, '--cwd',
|
|
377
|
+
args.splice(end < 0 ? args.length : end, 0, '--cwd', effectiveCwd);
|
|
331
378
|
}
|
|
332
379
|
}
|
|
333
380
|
return args;
|
|
@@ -352,6 +399,20 @@ export function isDirectResumeSelector(query) {
|
|
|
352
399
|
export async function resumeSelectorInPlace(selector) {
|
|
353
400
|
await spawnCliInPlace(['sessions', 'resume', selector]);
|
|
354
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Local preflight cannot inspect a peer's files or index. Leave peer validation
|
|
404
|
+
* to the existing origin-device recovery hop. --here without a remote surface
|
|
405
|
+
* opts into local recovery and therefore uses the local transcript guard.
|
|
406
|
+
*/
|
|
407
|
+
export function partitionResumableSelections(chosen, options = {}) {
|
|
408
|
+
const resumable = [];
|
|
409
|
+
const skipped = [];
|
|
410
|
+
for (const s of chosen) {
|
|
411
|
+
const deferredToPeer = (!options.here || !!options.device) && sessionRecoveryPeer(s);
|
|
412
|
+
(deferredToPeer || sessionTranscriptReadable(s) ? resumable : skipped).push(s);
|
|
413
|
+
}
|
|
414
|
+
return { resumable, skipped };
|
|
415
|
+
}
|
|
355
416
|
/** Direct identities use focus as the lifecycle dispatcher: it rechecks the
|
|
356
417
|
* live fleet, attaches a healthy pane, and falls through to `agents resume`
|
|
357
418
|
* only when the process is no longer attachable. */
|
|
@@ -395,6 +456,21 @@ async function spawnCliInPlace(args) {
|
|
|
395
456
|
export function resolveResumePacking(options) {
|
|
396
457
|
return options.splits ? 'two-per-tab' : 'tabs';
|
|
397
458
|
}
|
|
459
|
+
/** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
|
|
460
|
+
export function buildSelectedResumeSurface(session, prompt, options) {
|
|
461
|
+
const requestedCwd = options.cwd || session.cwd;
|
|
462
|
+
if (options.device && !requestedCwd) {
|
|
463
|
+
throw new Error(`Cannot open ${session.shortId} on ${options.device} without a recorded working directory. Pass --cwd <path>.`);
|
|
464
|
+
}
|
|
465
|
+
const cwd = options.device
|
|
466
|
+
? requestedCwd
|
|
467
|
+
: requestedCwd && fs.existsSync(requestedCwd) ? requestedCwd : process.cwd();
|
|
468
|
+
return {
|
|
469
|
+
session,
|
|
470
|
+
cwd,
|
|
471
|
+
command: ['agents', ...buildSelectedResumeArgs(session.id, prompt, options)].map(shellQuote),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
398
474
|
export function resumeHostMismatch(session, requestedHost, self = machineId()) {
|
|
399
475
|
const origin = sessionOriginDevice(session, self);
|
|
400
476
|
return sessionRecoveryDestinationMatches(session, requestedHost, self)
|