@phnx-labs/agents-cli 1.22.106 → 1.22.109

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 (44) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +9 -5
  3. package/dist/commands/daemon.js +45 -40
  4. package/dist/commands/doctor.js +7 -0
  5. package/dist/commands/exec.d.ts +78 -8
  6. package/dist/commands/exec.js +276 -53
  7. package/dist/commands/monitors.js +15 -2
  8. package/dist/commands/routines.js +46 -6
  9. package/dist/commands/run-account-picker.d.ts +11 -0
  10. package/dist/commands/run-account-picker.js +11 -1
  11. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  12. package/dist/commands/sessions-backup-setup.js +65 -0
  13. package/dist/commands/sessions-resume.d.ts +14 -1
  14. package/dist/commands/sessions-resume.js +56 -9
  15. package/dist/commands/sessions.js +2 -0
  16. package/dist/commands/share.js +15 -2
  17. package/dist/commands/sync.js +30 -1
  18. package/dist/lib/accounts/slots.js +7 -0
  19. package/dist/lib/agent-spec/agents.d.ts +60 -8
  20. package/dist/lib/agent-spec/agents.js +118 -45
  21. package/dist/lib/daemon/daemon.d.ts +34 -1
  22. package/dist/lib/daemon/daemon.js +63 -9
  23. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  24. package/dist/lib/daemon/leaked-daemons.js +180 -0
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  26. package/dist/lib/devices/doctor-findings.js +32 -1
  27. package/dist/lib/hooks/install.js +70 -63
  28. package/dist/lib/hosts/dispatch.d.ts +1 -1
  29. package/dist/lib/hosts/dispatch.js +1 -1
  30. package/dist/lib/models.js +76 -5
  31. package/dist/lib/session/cloud.js +3 -1
  32. package/dist/lib/session/parse.d.ts +1 -0
  33. package/dist/lib/session/parse.js +136 -2
  34. package/dist/lib/session/recovery.d.ts +9 -1
  35. package/dist/lib/session/recovery.js +14 -4
  36. package/dist/lib/session/tool-calls.d.ts +1 -1
  37. package/dist/lib/session/tool-calls.js +40 -5
  38. package/dist/lib/share/backend.d.ts +23 -0
  39. package/dist/lib/share/backend.js +24 -0
  40. package/dist/lib/share/provision.d.ts +12 -0
  41. package/dist/lib/share/provision.js +30 -0
  42. package/dist/lib/share/worker-template.js +273 -0
  43. package/dist/lib/terminal/engine.js +13 -1
  44. package/package.json +1 -1
@@ -204,6 +204,16 @@ export async function pickSwitchAccount(agent, rows) {
204
204
  throw err;
205
205
  }
206
206
  }
207
+ /**
208
+ * The two-condition "human-facing" gate behind signInLaunchDecision and
209
+ * noVerifiedUsageDecision: a real TTY and no `--json`. Off a TTY nobody can
210
+ * answer a prompt, and `--json` marks a MACHINE consumer, which must never be
211
+ * handed a picker or dropped into a login TUI. Mirrors the canonical
212
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`.
213
+ */
214
+ export function isHumanFacingRun(input) {
215
+ return input.tty && !input.json;
216
+ }
207
217
  /**
208
218
  * Whether a zero-healthy run may recover by launching for a login, or must keep
209
219
  * failing loud. Three inputs, all of which have to hold:
@@ -217,7 +227,7 @@ export async function pickSwitchAccount(agent, rows) {
217
227
  * gets the parseable fail-loud error instead.
218
228
  */
219
229
  export function signInLaunchDecision(input) {
220
- const humanPresent = input.tty && !input.json;
230
+ const humanPresent = isHumanFacingRun(input);
221
231
  return input.recoverable > 0 && humanPresent ? 'launch' : 'fail-loud';
222
232
  }
223
233
  /**
@@ -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
+ }
@@ -1,6 +1,6 @@
1
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;
@@ -33,6 +33,15 @@ export declare function isDirectResumeSelector(query: string): boolean;
33
33
  /** Re-enter through sessions resume so fleet routing and harness policy
34
34
  * stay centralized. The child inherits this terminal for a real interactive resume. */
35
35
  export declare function resumeSelectorInPlace(selector: string): Promise<void>;
36
+ /**
37
+ * Local preflight cannot inspect a peer's files or index. Leave peer validation
38
+ * to the existing origin-device recovery hop. --here without a remote surface
39
+ * opts into local recovery and therefore uses the local transcript guard.
40
+ */
41
+ export declare function partitionResumableSelections(chosen: SessionMeta[], options?: Pick<ResumeOptions, 'here' | 'device'>): {
42
+ resumable: SessionMeta[];
43
+ skipped: SessionMeta[];
44
+ };
36
45
  /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
37
46
  * live fleet, attaches a healthy pane, and falls through to `agents resume`
38
47
  * only when the process is no longer attachable. */
@@ -42,6 +51,10 @@ export declare function resumeUsesLifecycleDispatch(query: string | undefined, p
42
51
  export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): Promise<void>;
43
52
  export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[], attachOnly?: boolean, local?: boolean): string[];
44
53
  export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
54
+ /** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
55
+ export declare function buildSelectedResumeSurface(session: SessionMeta, prompt: string | undefined, options: ResumeOptions): SurfaceItem & {
56
+ session: SessionMeta;
57
+ };
45
58
  export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
46
59
  /**
47
60
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
@@ -19,15 +19,16 @@ import { multiItemPicker, itemPicker } from '../lib/picker.js';
19
19
  import { buildPreview } from './sessions-picker.js';
20
20
  import { formatPickerLabel, pickerColumnsFor, resolveSessionMetadataValue, parseAgentFilter, } from './sessions.js';
21
21
  import { sessionMatchesQuery } from './sessions-browser.js';
22
- import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
22
+ import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, shellQuote, } from '../lib/terminal/index.js';
23
23
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
24
24
  import { setHelpSections } from '../lib/help.js';
25
25
  import { confirm } from '@inquirer/prompts';
26
26
  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
- import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
29
+ import { sessionOriginDevice, sessionRecoveryDestinationMatches, sessionRecoveryPeer, sessionTranscriptReadable } from '../lib/session/recovery.js';
30
30
  import { buildResumeRemoteArgs, runStrictResume, wantsStrictResume } from './resume.js';
31
+ import { toRemotePortable } from '../lib/project-root.js';
31
32
  import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
32
33
  /** Opening more than this many live sessions at once asks for confirmation first. */
33
34
  export const CONFIRM_THRESHOLD = 5;
@@ -98,6 +99,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
98
99
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --device too).
99
100
  - --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
101
  - Recovery uses the installed harness and the conversation's account on its origin device. Context replay requires an explicit choice.
102
+ - Local picks without transcripts are skipped before opening a tab. Remote picks are validated on their origin device.
101
103
  - agents run claude --resume opens this same picker; claude#work filters it with --account work.
102
104
  `,
103
105
  });
@@ -232,12 +234,16 @@ export async function sessionsResumeAction(query, prompt, options) {
232
234
  }
233
235
  }
234
236
  // 2. Route every selection through the owning device's recovery resolver.
235
- const items = [];
236
- for (const s of chosen) {
237
- const command = ['agents', ...buildSelectedResumeArgs(s.id, prompt, options)];
238
- const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
239
- items.push({ session: s, cwd, command });
237
+ const { resumable, skipped } = partitionResumableSelections(chosen, options);
238
+ for (const s of skipped) {
239
+ console.log(chalk.yellow(`Skipping ${s.shortId} — nothing to resume (no transcript was written).`));
240
240
  }
241
+ if (resumable.length === 0) {
242
+ console.error(chalk.red('Nothing to resume — no selected session has a transcript.'));
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ const items = resumable.map((session) => buildSelectedResumeSurface(session, prompt, options));
241
247
  // 3. Resolve the backend (and host).
242
248
  const ctx = currentContext();
243
249
  const backend = await resolveBackend(options, ctx, items.length);
@@ -294,6 +300,8 @@ export async function sessionsResumeAction(query, prompt, options) {
294
300
  }
295
301
  });
296
302
  console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
303
+ if (opened !== items.length)
304
+ process.exitCode = 1;
297
305
  }
298
306
  /** Preserve run options and lifecycle intent when the picker opens its selected rows. */
299
307
  export function buildSelectedResumeArgs(id, prompt, options) {
@@ -307,6 +315,7 @@ export function buildSelectedResumeArgs(id, prompt, options) {
307
315
  // The outer surface already placed this terminal on the selected device.
308
316
  if (options.device) {
309
317
  let remoteCwd;
318
+ let localCwd;
310
319
  for (let i = 0; i < args.length && args[i] !== '--'; i++) {
311
320
  if (args[i] === '--remote-cwd') {
312
321
  remoteCwd = args[i + 1];
@@ -316,6 +325,14 @@ export function buildSelectedResumeArgs(id, prompt, options) {
316
325
  remoteCwd = args[i].slice('--remote-cwd='.length);
317
326
  args.splice(i--, 1);
318
327
  }
328
+ else if (args[i] === '--cwd') {
329
+ localCwd = args[i + 1];
330
+ args.splice(i--, 2);
331
+ }
332
+ else if (args[i].startsWith('--cwd=')) {
333
+ localCwd = args[i].slice('--cwd='.length);
334
+ args.splice(i--, 1);
335
+ }
319
336
  else if (['-D', '--device', '--host', '--where', '--on', '--computer'].includes(args[i])) {
320
337
  args.splice(i, 2);
321
338
  i--;
@@ -325,9 +342,10 @@ export function buildSelectedResumeArgs(id, prompt, options) {
325
342
  i--;
326
343
  }
327
344
  }
328
- if (remoteCwd !== undefined) {
345
+ const effectiveCwd = remoteCwd ?? options.cwd ?? (localCwd ? toRemotePortable(localCwd) : undefined);
346
+ if (effectiveCwd !== undefined) {
329
347
  const end = args.indexOf('--');
330
- args.splice(end < 0 ? args.length : end, 0, '--cwd', remoteCwd);
348
+ args.splice(end < 0 ? args.length : end, 0, '--cwd', effectiveCwd);
331
349
  }
332
350
  }
333
351
  return args;
@@ -352,6 +370,20 @@ export function isDirectResumeSelector(query) {
352
370
  export async function resumeSelectorInPlace(selector) {
353
371
  await spawnCliInPlace(['sessions', 'resume', selector]);
354
372
  }
373
+ /**
374
+ * Local preflight cannot inspect a peer's files or index. Leave peer validation
375
+ * to the existing origin-device recovery hop. --here without a remote surface
376
+ * opts into local recovery and therefore uses the local transcript guard.
377
+ */
378
+ export function partitionResumableSelections(chosen, options = {}) {
379
+ const resumable = [];
380
+ const skipped = [];
381
+ for (const s of chosen) {
382
+ const deferredToPeer = (!options.here || !!options.device) && sessionRecoveryPeer(s);
383
+ (deferredToPeer || sessionTranscriptReadable(s) ? resumable : skipped).push(s);
384
+ }
385
+ return { resumable, skipped };
386
+ }
355
387
  /** Direct identities use focus as the lifecycle dispatcher: it rechecks the
356
388
  * live fleet, attaches a healthy pane, and falls through to `agents resume`
357
389
  * only when the process is no longer attachable. */
@@ -395,6 +427,21 @@ async function spawnCliInPlace(args) {
395
427
  export function resolveResumePacking(options) {
396
428
  return options.splits ? 'two-per-tab' : 'tabs';
397
429
  }
430
+ /** Surface commands use shell words; spawnCliInPlace keeps the original argv. */
431
+ export function buildSelectedResumeSurface(session, prompt, options) {
432
+ const requestedCwd = options.cwd || session.cwd;
433
+ if (options.device && !requestedCwd) {
434
+ throw new Error(`Cannot open ${session.shortId} on ${options.device} without a recorded working directory. Pass --cwd <path>.`);
435
+ }
436
+ const cwd = options.device
437
+ ? requestedCwd
438
+ : requestedCwd && fs.existsSync(requestedCwd) ? requestedCwd : process.cwd();
439
+ return {
440
+ session,
441
+ cwd,
442
+ command: ['agents', ...buildSelectedResumeArgs(session.id, prompt, options)].map(shellQuote),
443
+ };
444
+ }
398
445
  export function resumeHostMismatch(session, requestedHost, self = machineId()) {
399
446
  const origin = sessionOriginDevice(session, self);
400
447
  return sessionRecoveryDestinationMatches(session, requestedHost, self)
@@ -75,6 +75,7 @@ import { registerSessionsRenderCommand } from './sessions-render.js';
75
75
  import { registerSessionsTraceCommand } from './sessions-trace.js';
76
76
  import { registerSessionsShareCommand } from './sessions-share.js';
77
77
  import { registerSessionsImportCommand } from './sessions-import.js';
78
+ import { registerSessionsBackupSetupCommand } from './sessions-backup-setup.js';
78
79
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
79
80
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
80
81
  import { registerSessionsStatsCommand } from './sessions-stats.js';
@@ -5387,6 +5388,7 @@ export function registerSessionsCommands(program) {
5387
5388
  registerSessionsTraceCommand(sessionsCmd);
5388
5389
  registerSessionsShareCommand(sessionsCmd);
5389
5390
  registerSessionsImportCommand(sessionsCmd);
5391
+ registerSessionsBackupSetupCommand(sessionsCmd);
5390
5392
  registerSessionsMigrateCommand(sessionsCmd);
5391
5393
  registerSessionsMigrationsCommand(sessionsCmd);
5392
5394
  registerSessionsBackfillCommand(sessionsCmd);
@@ -10,13 +10,13 @@ import { formatBytes } from '../lib/format.js';
10
10
  import { Argument, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
13
- import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
13
+ import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, WORKER_COLLAB_BASE_SECRET, WORKER_COLLAB_SERVICE_TOKEN_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
14
  import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
18
18
  import { extractShareHttpError, formatShareHttpErrorDetail } from '../lib/share/http-error.js';
19
- import { phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
19
+ import { collabConfigForDeploy, phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
20
20
  import { publishVisibility } from '../lib/storage/visibility.js';
21
21
  import { resolveGitHubUsername } from '../lib/git.js';
22
22
  import { refreshSessionAvatar } from '../lib/identity/index.js';
@@ -1271,6 +1271,15 @@ export async function runShareProvision(opts) {
1271
1271
  await putWorkerSecret(apiToken, accountId, workerName, WORKER_PHOENIX_ID_BASE_SECRET, phoenixIdBase, provisionOpts);
1272
1272
  spin.text = `Worker '${workerName}' Phoenix ID base set`;
1273
1273
  }
1274
+ // Managed collaboration backend (PHNX-3835). Dormant unless BOTH
1275
+ // PRIX_ARTIFACT_COLLAB_BASE and ARTIFACT_COLLAB_SERVICE_TOKEN are set in the
1276
+ // deploy env; the service token rides the Secrets API, never rendered HTML.
1277
+ const collab = collabConfigForDeploy({ managed: opts.managed }, { baseUrl, domain });
1278
+ if (collab.collabBase && collab.collabServiceToken) {
1279
+ await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_BASE_SECRET, collab.collabBase, provisionOpts);
1280
+ await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_SERVICE_TOKEN_SECRET, collab.collabServiceToken, provisionOpts);
1281
+ spin.text = `Worker '${workerName}' collaboration backend set`;
1282
+ }
1274
1283
  spin.succeed('Provisioned');
1275
1284
  const cfg = {
1276
1285
  baseUrl,
@@ -1336,10 +1345,14 @@ export async function runShareUpdate(opts = {}) {
1336
1345
  }
1337
1346
  const writeToken = readWriteToken();
1338
1347
  const phoenixIdBase = phoenixIdBaseForDeploy({ managed: opts.managed }, cfg);
1348
+ const collab = collabConfigForDeploy({ managed: opts.managed }, cfg);
1339
1349
  const provisionOpts = {
1340
1350
  ...(opts.request ? { request: opts.request } : {}),
1341
1351
  force: opts.force,
1342
1352
  ...(phoenixIdBase !== undefined ? { phoenixIdBase } : {}),
1353
+ ...(collab.collabBase !== undefined
1354
+ ? { collabBase: collab.collabBase, collabServiceToken: collab.collabServiceToken }
1355
+ : {}),
1343
1356
  };
1344
1357
  const result = await updateWorker(apiToken, accountId, cfg.workerName, cfg.bucketName, worker, writeToken, cfg.templateHash, provisionOpts);
1345
1358
  if (!result.skipped) {
@@ -190,7 +190,7 @@ export function registerSyncCommand(program) {
190
190
  .option('-y, --yes', 'Skip the interactive preview and auto-sync all detected resources', false)
191
191
  .option('--force', 'Re-sync even if no changes are detected since the last sync', false)
192
192
  .option('--quiet', 'Suppress all output (exit code indicates success)', false)
193
- .option('--dry-run', 'Show what would be synced without making any changes', false)
193
+ .option('--dry-run', 'Show what would be synced without making any changes — requires an agent scope (e.g. agents sync claude --dry-run); the umbrella verb refuses it', false)
194
194
  .option('--allow-exec-surfaces', 'Allow syncing plugin exec surfaces (scripts, binaries) — off by default for safety', false)
195
195
  .option('--json', 'Emit machine-readable JSON (also accepted so fleet fan-out via --device all can parse each peer)', false)
196
196
  // Umbrella verb (no agent given): make this machine current.
@@ -480,6 +480,35 @@ function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
480
480
  * a one-line summary. Stage failures are non-fatal and surfaced as warnings.
481
481
  */
482
482
  async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
483
+ // `--dry-run` on the umbrella verb is NOT supported and must fail LOUD before
484
+ // touching anything (PHNX-3923). The umbrella composes stages that only exist
485
+ // as mutating operations — repo `git pull`, a full `refresh()` reconcile into
486
+ // every installed version home, central browser-profile eviction, device sync,
487
+ // and `repairAfterSync` — none of which carry a non-mutating preview mode. The
488
+ // old code ignored `opts.dryRun` entirely, ran `runUmbrellaSync` + evict +
489
+ // repair, and so MUTATED every native home despite `--dry-run`. Rather than
490
+ // ship a partial preview that silently skips the stages it cannot model (a
491
+ // lying "would sync" that contradicts the flag's promise), refuse here and
492
+ // point at the scoped path, which DOES honor `--dry-run` non-destructively.
493
+ if (opts.dryRun) {
494
+ const installed = MANAGED_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
495
+ const example = installed[0] ?? 'claude';
496
+ const error = '`agents sync --dry-run` has no umbrella preview: the machine-wide sync pulls repos, ' +
497
+ 'reconciles every installed version, syncs devices, and repairs homes — stages that ' +
498
+ 'cannot be previewed without making changes.';
499
+ const hint = `Preview one agent instead (this is non-destructive): agents sync ${example} --dry-run [--repo <repo>]`;
500
+ if (json) {
501
+ emitJson({ ok: false, mode: 'umbrella', dryRun: true, error, hint, installedAgents: installed });
502
+ }
503
+ else {
504
+ errLog(chalk.red(error));
505
+ errLog(chalk.gray(hint));
506
+ if (installed.length > 0)
507
+ errLog(chalk.gray(`Installed agents: ${installed.join(', ')}`));
508
+ }
509
+ process.exitCode = 1;
510
+ return;
511
+ }
483
512
  // Interactive bare `agents sync` (a TTY, no --yes, no scope flag) drops into
484
513
  // the two-checklist picker: which repos to sync from, which agents to sync
485
514
  // into. Any explicit flag, --yes, or --json keeps the non-interactive path.
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { agentConfigDirName } from '../agents.js';
17
17
  import { harnessAuth, harnessWorkerIsPerDevice } from '../harness-auth-capabilities.js';
18
+ import { installSessionTrackerHookSync } from '../hooks/install.js';
18
19
  import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from '../installations/store.js';
19
20
  import { carryForwardSettings } from '../settings-manifest.js';
20
21
  import { getHistoryDir, readMeta, updateMeta } from '../state.js';
@@ -71,6 +72,12 @@ function projectResources(harness, version, destHome, fromHome) {
71
72
  continue;
72
73
  writer.write({ version, versionHome: destHome, selection: names, cwd });
73
74
  }
75
+ if (supports(harness, 'hooks', version).ok) {
76
+ const tracker = installSessionTrackerHookSync(harness, version, destHome);
77
+ if (!tracker.installed && tracker.error) {
78
+ console.warn(`agents: SessionStart hook not installed for ${harness} account home ${destHome}: ${tracker.error}`);
79
+ }
80
+ }
74
81
  }
75
82
  /**
76
83
  * Remove slot artifacts whose source name is gone. Only the name-keyed kinds
@@ -355,25 +355,77 @@ export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
355
355
  } | null;
356
356
  /** @internal test hook — clear the per-process keyring probe cache. */
357
357
  export declare function __resetAntigravityKeychainCacheForTest(): void;
358
+ /** The XDG base dirs OpenCode reads, and the env var that overrides each. */
359
+ declare const OPENCODE_XDG_DIRS: {
360
+ readonly data: {
361
+ readonly env: "XDG_DATA_HOME";
362
+ readonly fallback: readonly [".local", "share"];
363
+ };
364
+ readonly state: {
365
+ readonly env: "XDG_STATE_HOME";
366
+ readonly fallback: readonly [".local", "state"];
367
+ };
368
+ };
369
+ /**
370
+ * Resolve one of OpenCode's (sst/opencode) XDG-rooted files.
371
+ *
372
+ * OpenCode keeps provider credentials under `$XDG_DATA_HOME/opencode/` and TUI
373
+ * state under `$XDG_STATE_HOME/opencode/`, defaulting to `~/.local/share` and
374
+ * `~/.local/state` on EVERY platform — its `xdg-basedir` dependency does not
375
+ * special-case macOS, so there is no `~/Library/Application Support` variant.
376
+ * Both roots are account-global (not per-version), matching how
377
+ * `session/discover.ts` already resolves `~/.local/share/opencode/opencode.db`.
378
+ *
379
+ * Resolution order, first existing wins:
380
+ * 1. `<base>/<fallback>/opencode/<file>` — the passed per-version home. This is
381
+ * primarily a test hook (suites write a hermetic file under a temp home)
382
+ * but also covers any relocated install.
383
+ * 2. `$XDG_<KIND>_HOME/opencode/<file>` — an explicit XDG override, exactly
384
+ * what OpenCode itself honours.
385
+ * 3. `<realHome>/<fallback>/opencode/<file>` — the active default, under
386
+ * `AGENTS_REAL_HOME` or `os.homedir()`, so every installed version reflects
387
+ * the one account-global state (same fallback shape as
388
+ * resolveAccountCredentialPath).
389
+ * Returns the first existing path, or null. Never throws.
390
+ */
391
+ export declare function resolveOpenCodeXdgPath(base: string, kind: keyof typeof OPENCODE_XDG_DIRS, file: string): string | null;
392
+ /** OpenCode's signed-in identity, as far as `auth.json` can describe it. */
393
+ export interface OpenCodeIdentity {
394
+ /** Sorted, "+"-joined provider ids holding a valid credential. */
395
+ providers: string;
396
+ /** Account email, when some OAuth credential's token carries the claim. */
397
+ email: string | null;
398
+ /** Plan tier from the same token (e.g. `Pro`), when present. */
399
+ plan: string | null;
400
+ }
358
401
  /**
359
- * OpenCode's account identity: the sorted, "+"-joined list of provider ids
360
- * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
361
- * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
362
- * so this join is the closest thing to "which account is this" available — the
363
- * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
402
+ * OpenCode's account identity, read from `auth.json`.
403
+ *
404
+ * The provider join (`"meta+openai+opencode-go"`) is the stable key: OpenCode is
405
+ * a multi-provider harness, so "which providers are configured" is what actually
406
+ * identifies an install, and `session/discover.ts` indexes sessions by it.
407
+ * Providers that sign in over OAuth additionally hand OpenCode a token with real
408
+ * identity claims, so the email and plan are surfaced alongside it instead of
409
+ * leaving `agents view` showing a bare `id:` key for a login that knows exactly
410
+ * whose it is. Providers are walked in sorted order so a multi-OAuth install
411
+ * resolves to the same email on every machine.
364
412
  *
365
413
  * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
366
414
  * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
367
415
  * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
368
416
  * migrations) all three are permanently empty — no migration ever populates
369
417
  * them, and no session has ever written a row. Reading from them instead of
370
- * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
371
- * uses this function rather than duplicating a sqlite lookup against those
372
- * dead tables.
418
+ * `auth.json` always yields `undefined`, credential or not.
373
419
  *
374
420
  * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
375
421
  * missing, unreadable, or carries no valid credential.
376
422
  */
423
+ export declare function resolveOpenCodeIdentity(base: string): OpenCodeIdentity | undefined;
424
+ /**
425
+ * The provider join alone — the value `session/discover.ts` indexes sessions by,
426
+ * kept as its own entry point so callers that only need the key do not have to
427
+ * know about the OAuth claim walk.
428
+ */
377
429
  export declare function resolveOpenCodeAccountId(base: string): string | undefined;
378
430
  /**
379
431
  * Whether a Claude version home's credential file is present but carries no