@phnx-labs/agents-cli 1.22.24 → 1.22.26

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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -12,7 +12,7 @@ import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
- import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
15
+ import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
17
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, computeProjectGroup, computeProjectGroupKind, projectGroupKey, projectGroupTitle, projectGroupOrder, normalizeProjects, } from '../lib/routines.js';
18
18
  import { listProjectDefs, isSafeProjectName } from '../lib/projects.js';
@@ -21,7 +21,7 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
21
21
  import { getRoutinesDir } from '../lib/state.js';
22
22
  import { IS_WINDOWS } from '../lib/platform/index.js';
23
23
  import { safeJoin } from '../lib/paths.js';
24
- import { executeJob, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
24
+ import { executeJob, monitorRunningJobs } from '../lib/runner.js';
25
25
  import { JobScheduler } from '../lib/scheduler.js';
26
26
  import { detectOverdueJobs } from '../lib/overdue.js';
27
27
  import { runCatchup } from '../lib/catchup.js';
@@ -405,6 +405,72 @@ function runMetaJson(run) {
405
405
  export function buildRunsJson(runs) {
406
406
  return runs.map(runMetaJson);
407
407
  }
408
+ /** Build the exact structured routine rows shared by `routines list --json`
409
+ * and the one-process AGI Menu snapshot. */
410
+ export function buildRoutineListJson() {
411
+ try {
412
+ monitorRunningJobs();
413
+ }
414
+ catch { /* best-effort orphan reap */ }
415
+ const jobs = listAllJobs(process.cwd());
416
+ if (jobs.length === 0)
417
+ return [];
418
+ const scheduler = new JobScheduler(async () => { });
419
+ scheduler.loadAll();
420
+ try {
421
+ const overdueSet = new Set();
422
+ try {
423
+ for (const job of detectOverdueJobs())
424
+ overdueSet.add(job.name);
425
+ }
426
+ catch {
427
+ // Best-effort indicator; never block the list on detection errors.
428
+ }
429
+ const now = new Date();
430
+ const knownProjectNames = new Set(listProjectDefs().map((project) => project.name));
431
+ return jobs.map((job) => {
432
+ const latestRun = localLatestRun(job);
433
+ const enabledDevices = devicesWithRoutineEnabled(job.name);
434
+ return {
435
+ name: job.name,
436
+ agent: job.agent ?? null,
437
+ workflow: job.workflow ?? null,
438
+ command: job.command ?? null,
439
+ repo: job.repo ?? null,
440
+ schedule: job.schedule ?? null,
441
+ scheduleHuman: fireConditionLabel(job),
442
+ trigger: job.trigger ?? null,
443
+ timezone: job.timezone ?? null,
444
+ devices: enabledDevices,
445
+ enabledDevices,
446
+ host: job.host ?? null,
447
+ hostStrategy: resolveHostStrategy(job),
448
+ source: job.source ?? null,
449
+ sourceRepo: job.source?.repo ?? job.repo ?? null,
450
+ sourceBranch: job.source?.branch ?? null,
451
+ runOnce: Boolean(job.runOnce),
452
+ catchup: job.catchup !== false,
453
+ oneShot: isOneShotRoutine(job),
454
+ expired: isPastOneShotRoutine(job, now),
455
+ runsHere: jobRunsOnThisDevice(job),
456
+ enabled: job.enabled,
457
+ overdue: overdueSet.has(job.name),
458
+ nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
459
+ nextRunHuman: nextRunLabel(job, scheduler, now),
460
+ lastStatus: latestRun?.status ?? null,
461
+ exitCode: latestRun?.exitCode ?? null,
462
+ failureReason: latestRun?.errorMessage ?? null,
463
+ lastRunStartedAt: latestRun?.startedAt ?? null,
464
+ lastRunCompletedAt: latestRun?.completedAt ?? null,
465
+ projects: job.projects ?? [],
466
+ projectGroup: computeProjectGroup(job.projects, knownProjectNames),
467
+ };
468
+ });
469
+ }
470
+ finally {
471
+ scheduler.stopAll();
472
+ }
473
+ }
408
474
  /** Detect Ctrl+C or premature stream close during an interactive prompt. */
409
475
  function isPromptCancelled(err) {
410
476
  return (err instanceof Error &&
@@ -554,6 +620,10 @@ export function registerRoutinesCommands(program) {
554
620
  console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: project (default) or device`));
555
621
  process.exit(1);
556
622
  }
623
+ if (options.json) {
624
+ process.stdout.write(JSON.stringify(buildRoutineListJson()) + '\n');
625
+ return;
626
+ }
557
627
  try {
558
628
  monitorRunningJobs();
559
629
  }
@@ -579,53 +649,6 @@ export function registerRoutinesCommands(program) {
579
649
  catch {
580
650
  // Best-effort indicator; never block the list on detection errors.
581
651
  }
582
- // Machine-readable path: same data the table renders, but structured.
583
- // The menu bar helper relies on this so it never reimplements cron math.
584
- if (options.json) {
585
- const nowJson = new Date();
586
- const knownProjectNames = new Set(listProjectDefs().map((p) => p.name));
587
- const payload = jobs.map((job) => {
588
- const latestRun = localLatestRun(job);
589
- const enabledDevices = devicesWithRoutineEnabled(job.name);
590
- return {
591
- name: job.name,
592
- agent: job.agent ?? null,
593
- workflow: job.workflow ?? null,
594
- command: job.command ?? null,
595
- repo: job.repo ?? null,
596
- schedule: job.schedule ?? null,
597
- scheduleHuman: fireConditionLabel(job),
598
- trigger: job.trigger ?? null,
599
- timezone: job.timezone ?? null,
600
- devices: enabledDevices,
601
- enabledDevices,
602
- host: job.host ?? null,
603
- hostStrategy: resolveHostStrategy(job),
604
- source: job.source ?? null,
605
- sourceRepo: job.source?.repo ?? job.repo ?? null,
606
- sourceBranch: job.source?.branch ?? null,
607
- runOnce: Boolean(job.runOnce),
608
- catchup: job.catchup !== false,
609
- oneShot: isOneShotRoutine(job),
610
- expired: isPastOneShotRoutine(job, nowJson),
611
- runsHere: jobRunsOnThisDevice(job),
612
- enabled: job.enabled,
613
- overdue: overdueSet.has(job.name),
614
- nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
615
- nextRunHuman: nextRunLabel(job, scheduler, nowJson),
616
- lastStatus: latestRun?.status ?? null,
617
- exitCode: latestRun?.exitCode ?? null,
618
- failureReason: latestRun?.errorMessage ?? null,
619
- lastRunStartedAt: latestRun?.startedAt ?? null,
620
- lastRunCompletedAt: latestRun?.completedAt ?? null,
621
- projects: job.projects ?? [],
622
- projectGroup: computeProjectGroup(job.projects, knownProjectNames),
623
- };
624
- });
625
- scheduler.stopAll();
626
- process.stdout.write(JSON.stringify(payload) + '\n');
627
- return;
628
- }
629
652
  console.log(chalk.bold('Scheduled Jobs\n'));
630
653
  // OSC 8 hyperlink helper — renders as a clickable link in supporting terminals.
631
654
  // Guarded on process.stdout.isTTY so that piped/redirected output never
@@ -91,7 +91,7 @@ export function resolveRunCloudProvider(agentId, explicitProvider) {
91
91
  return resolveProvider(explicitProvider);
92
92
  if (!nativeProviderForAgent(agentId)) {
93
93
  throw new RunCloudError(`${agentId} has no native cloud. Cloud-capable agents: ${cloudCapableAgentIds().join(', ')}. ` +
94
- `Override with --provider <id> (rush | codex | factory | antigravity | host).`);
94
+ `Override with --provider <id> (rush | codex | cursor | factory | antigravity | host).`);
95
95
  }
96
96
  return resolveProvider(undefined, agentId);
97
97
  }
@@ -8,6 +8,8 @@
8
8
  import { type Command } from 'commander';
9
9
  import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
10
  import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
+ import { bundleEnvToDotenv } from '../lib/secrets/push.js';
12
+ export { bundleEnvToDotenv };
11
13
  import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
12
14
  import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
13
15
  import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
@@ -78,14 +80,6 @@ export { SSH_TARGET_RE, assertValidSshTarget };
78
80
  * the executed command.
79
81
  */
80
82
  export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secretEnv: Record<string, string>): NodeJS.ProcessEnv;
81
- /**
82
- * Serialize a resolved env map to `.env` lines that round-trip losslessly through
83
- * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
84
- * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
85
- * value survives unchanged with no escaping. Newlines would break its line-based
86
- * parse, so multi-line values are rejected rather than silently corrupted.
87
- */
88
- export declare function bundleEnvToDotenv(env: Record<string, string>): string;
89
83
  /**
90
84
  * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
91
85
  * (the same EncFile envelope as the per-item file store). Inner plaintext is
@@ -12,12 +12,12 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
12
12
  import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
- import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
15
+ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
16
16
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
17
17
  import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
- import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, } from '../lib/secrets/remote.js';
19
- import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
- import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
19
+ import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
20
+ export { bundleEnvToDotenv };
21
21
  import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
@@ -354,24 +354,6 @@ function getCliVersion() {
354
354
  return '0.0.0';
355
355
  }
356
356
  }
357
- /**
358
- * Serialize a resolved env map to `.env` lines that round-trip losslessly through
359
- * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
360
- * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
361
- * value survives unchanged with no escaping. Newlines would break its line-based
362
- * parse, so multi-line values are rejected rather than silently corrupted.
363
- */
364
- export function bundleEnvToDotenv(env) {
365
- const lines = [];
366
- for (const [k, v] of Object.entries(env)) {
367
- if (/[\r\n]/.test(v)) {
368
- throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
369
- `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
370
- }
371
- lines.push(`${k}="${v}"`);
372
- }
373
- return lines.join('\n') + '\n';
374
- }
375
357
  /**
376
358
  * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
377
359
  * (the same EncFile envelope as the per-item file store). Inner plaintext is
@@ -2115,100 +2097,42 @@ Examples:
2115
2097
  if (hosts.length > 0) {
2116
2098
  for (const h of hosts)
2117
2099
  assertValidSshTarget(h);
2118
- const remoteBackend = parseBackendOpt(opts.remoteBackend);
2100
+ const parsedBackend = parseBackendOpt(opts.remoteBackend);
2101
+ // `--remote-backend` documents keychain|file only, and parseBackendOpt
2102
+ // exits on anything else — but its return type still admits 'vault',
2103
+ // which has no remote-push path. Refuse it here rather than let it fall
2104
+ // through to the keychain branch and half-work.
2105
+ if (parsedBackend === 'vault') {
2106
+ console.error(chalk.red("--remote-backend vault is not supported; use 'keychain' or 'file'."));
2107
+ process.exit(1);
2108
+ }
2109
+ const remoteBackend = parsedBackend;
2119
2110
  // For a file-backed remote bundle a passphrase is OPTIONAL. The file
2120
2111
  // store is passphrase-free by default: with AGENTS_SECRETS_PASSPHRASE
2121
2112
  // unset the remote `import --backend file` auto-provisions the remote's
2122
2113
  // own machine-local key (0600 under ~/.agents/.secrets-key/), so reads
2123
2114
  // are HEADLESS. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE only when
2124
- // the operator opts in by setting it (e.g. to key the bundle off-disk
2125
- // under a shared secret) shipped as the FIRST stdin line so it never
2126
- // lands in argv / `ps` / the remote shell history. Forcing a shared
2127
- // passphrase would defeat headless reads, so we no longer require one.
2115
+ // the operator opts in by setting it. Forcing a shared passphrase would
2116
+ // defeat headless reads, so we no longer require one.
2128
2117
  const remotePassphrase = remoteBackend === 'file' ? (process.env.AGENTS_SECRETS_PASSPHRASE ?? '') : '';
2129
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: true });
2130
- const dotenv = bundleEnvToDotenv(env);
2131
- const keyCount = Object.keys(env).length;
2132
- // Drive the remote's own `agents secrets import --from -` so the values
2133
- // land in its chosen backend, reading the .env off ssh stdin (never
2134
- // parsed by a remote shell — `--from -` replaces the POSIX-only
2135
- // `/dev/stdin`). The keychain path is built OS-aware via
2136
- // `remoteSecretsRaw` (bash -lc on POSIX, PowerShell on Windows), so it
2137
- // works on macOS, Linux AND Windows targets. `import` auto-creates the
2138
- // bundle, so no separate `create` (the old `|| true` was a POSIXism
2139
- // that broke on PowerShell: `'true' is not recognized`).
2118
+ // Resolve ONCE for N hosts reading a bundle can prompt, and doing it
2119
+ // per host would prompt per host.
2120
+ const resolvedForPush = resolveBundleForPush(resolvedBundleName, 'ssh export');
2121
+ const keyCount = resolvedForPush.keyCount;
2140
2122
  let failures = 0;
2141
2123
  for (const host of hosts) {
2142
- let res;
2143
- if (remoteBackend === 'file') {
2144
- // File backend: headless-readable via the remote's machine-local key
2145
- // when no passphrase is set; otherwise forwards AGENTS_SECRETS_PASSPHRASE
2146
- // as the FIRST stdin line (consumed by `read`, so it never lands in
2147
- // argv / `ps` / remote history), then the .env. Both build a POSIX
2148
- // `bash -lc` command — refuse a Windows target cleanly rather than
2149
- // emit broken PowerShell.
2150
- if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2151
- failures++;
2152
- console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
2153
- continue;
2154
- }
2155
- const { remoteCmd, input } = buildRemoteFileImportCommand(resolvedBundleName, dotenv, {
2156
- passphrase: remotePassphrase,
2157
- force: opts.force,
2158
- });
2159
- res = sshExec(host, remoteCmd, { input });
2160
- }
2161
- else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2162
- // Keychain on a Windows target: the `agents.ps1` shim doesn't
2163
- // forward ssh-piped stdin to node, so `--from -` would hang.
2164
- // Bridge the piped .env through PowerShell into a temp file and
2165
- // import `--from <file>` (deleted afterwards). Same hardened ssh
2166
- // engine, .env still only ever crosses the wire over ssh stdin.
2167
- res = sshExec(host, buildWindowsStdinImportCommand(resolvedBundleName, { force: opts.force }), { input: dotenv });
2168
- }
2169
- else {
2170
- // Keychain on a POSIX target: OS-aware wrapping + hardened ssh
2171
- // engine (BatchMode, ConnectTimeout, keepalive, control-socket
2172
- // reuse) via the same path the READ inverse (`remoteResolveEnv`)
2173
- // uses. `--from -` reads the .env off ssh stdin.
2174
- res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv, osLookupName: host });
2175
- }
2176
- if (res.code === null) {
2177
- failures++;
2178
- console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
2179
- continue;
2180
- }
2181
- if (res.code !== 0) {
2124
+ const out = pushResolvedBundleToHost(resolvedForPush, resolvedBundleName, host, {
2125
+ remoteBackend,
2126
+ force: opts.force,
2127
+ passphrase: remotePassphrase,
2128
+ operation: 'ssh export',
2129
+ });
2130
+ if (!out.ok) {
2182
2131
  failures++;
2183
- const msg = (res.stderr || res.stdout || '').trim();
2184
- console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
2132
+ console.error(chalk.red(`${host}: ${out.message}`));
2185
2133
  continue;
2186
2134
  }
2187
- // A keychain-backed push to a macOS remote over headless SSH can land
2188
- // the bundle metadata but no READABLE value items: the remote login
2189
- // keychain is locked in the non-interactive SSH context, so Security
2190
- // accepts the write but the biometry-ACL'd item is unreadable, and the
2191
- // remote `import` still exits 0. Read the bundle back the same way a
2192
- // release will (drops the plaintext, keeps only key presence; the
2193
- // remote's headless `agentOnly` guard fails fast, so no Touch ID) and
2194
- // FAIL LOUDLY if the keys didn't materialize — rather than leave a
2195
- // metadata-only bundle that breaks later with "stored item not found".
2196
- // The file backend is headless-readable by construction, so skip it.
2197
- if (remoteBackend === 'keychain') {
2198
- const verdict = verifyRemoteKeychainPush(host, resolvedBundleName, Object.keys(env), { osLookupName: host });
2199
- if (!verdict.ok) {
2200
- failures++;
2201
- if (verdict.kind === 'locked-keychain') {
2202
- console.error(chalk.red(keychainWriteFailureMessage(host, resolvedBundleName, verdict.reason)));
2203
- }
2204
- else {
2205
- console.error(chalk.red(`${host}: pushed '${resolvedBundleName}' but could not verify it on the remote: ${verdict.reason}`));
2206
- }
2207
- continue;
2208
- }
2209
- }
2210
- const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
2211
- console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
2135
+ console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${out.message}`));
2212
2136
  }
2213
2137
  emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --host ${hosts.join(',')}`, source: 'ssh', host: hosts.join(','), status: failures > 0 ? 'error' : 'success', keyCount });
2214
2138
  if (failures > 0)
@@ -147,7 +147,7 @@ export declare function liveSessionToMeta(a: ActiveSession, self: string): Sessi
147
147
  * `--active --json` listed it. Live sessions the pool lacks are appended as their
148
148
  * own rows, then the whole set is grouped local-machine-first.
149
149
  */
150
- export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
150
+ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string, includeUnindexed?: boolean): SessionMeta[];
151
151
  /**
152
152
  * Whether to render the host-program column. It belongs to the running view
153
153
  * only, so this gates on the FILTER — not on the live index being populated.
@@ -22,7 +22,7 @@ import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
22
22
  import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
23
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
24
24
  import { buildPreview } from './sessions-picker.js';
25
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
25
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
26
26
  /**
27
27
  * Complete a seed into the filter the picker actually runs on.
28
28
  *
@@ -73,6 +73,8 @@ function poolCacheKey(f) {
73
73
  }
74
74
  /** Pool size when a team filter is active; one team's rows can sit anywhere. */
75
75
  const WHOLE_TEAM_POOL_LIMIT = 5000;
76
+ /** A cold peer may need to resolve an installed-version alias and scan its index. */
77
+ const BROWSER_PEER_TIMEOUT_MS = 30_000;
76
78
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
77
79
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
78
80
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -309,7 +311,9 @@ async function fetchRawPool(f, self, local, hosts, fixedFilters = false) {
309
311
  // --in-team itself, which a peer on an older build would reject as an
310
312
  // unknown option and fail the whole fan-out.
311
313
  const forwarded = remotePoolArgs(f, fixedFilters);
312
- const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
314
+ const remoteResult = await gatherRemoteList(forwarded, remoteHosts, {
315
+ timeoutMs: BROWSER_PEER_TIMEOUT_MS,
316
+ });
313
317
  unreachable = remoteResult.unreachable;
314
318
  if (remoteResult.sessions.length > 0)
315
319
  rows = mergeLocalFirst([...rows, ...remoteResult.sessions], self);
@@ -423,7 +427,12 @@ export function liveSessionToMeta(a, self) {
423
427
  * `--active --json` listed it. Live sessions the pool lacks are appended as their
424
428
  * own rows, then the whole set is grouped local-machine-first.
425
429
  */
426
- export function mergeLiveIntoPool(rows, live, self) {
430
+ export function mergeLiveIntoPool(rows, live, self, includeUnindexed = true) {
431
+ // A latest/oldest selector is resolved by the peer's transcript query. A live
432
+ // row absent from that result has no proof it belongs to the resolved version,
433
+ // so callers using an alias fail closed instead of widening the selector.
434
+ if (!includeUnindexed)
435
+ return rows;
427
436
  const known = new Set(rows.map((r) => r.id));
428
437
  const extra = [];
429
438
  for (const [key, a] of live) {
@@ -483,8 +492,15 @@ export function applyFilters(rows, live, f, self, favorites) {
483
492
  const cwd = process.cwd();
484
493
  out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
485
494
  }
486
- if (f.running)
487
- out = out.filter((r) => live.has(r.id));
495
+ // The live registry retains dead rows so explicit --closed/--crashed filters
496
+ // can recover them. Bare --active means currently active; a lifecycle filter
497
+ // intentionally replaces that default predicate with its exact status union.
498
+ if (f.running && f.statuses.length === 0) {
499
+ out = out.filter((r) => {
500
+ const active = live.get(r.id);
501
+ return !!active && isRunningLiveSession(active);
502
+ });
503
+ }
488
504
  if (f.statuses.length > 0) {
489
505
  out = out.filter((r) => {
490
506
  const active = live.get(r.id);
@@ -507,8 +523,9 @@ export async function collectSessionCandidates(initial, opts = {}) {
507
523
  const { sessions } = await gatherActiveSessions({ local: opts.local ?? false, hosts });
508
524
  liveById = indexLiveRows(sessions, self);
509
525
  }
526
+ const includeUnindexedLive = !filter.agent?.match(/@(latest|oldest)$/);
510
527
  const rows = filter.running || opts.includeLive
511
- ? mergeLiveIntoPool(pool.rows, liveById, self)
528
+ ? mergeLiveIntoPool(pool.rows, liveById, self, includeUnindexedLive)
512
529
  : pool.rows;
513
530
  const sessions = applyFilters(rows, liveById, filter, self, listFavorites());
514
531
  return { sessions, liveById, self, unreachable: pool.unreachable };
@@ -618,7 +635,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
618
635
  liveCache = live;
619
636
  // Live sessions the transcript pool lacks become rows of their own, so the
620
637
  // running view lists every active session, not just the ones already indexed.
621
- const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
638
+ const includeUnindexedLive = !f.agent?.match(/@(latest|oldest)$/);
639
+ const rows = f.running && live
640
+ ? mergeLiveIntoPool(pool.rows, live, self, includeUnindexedLive)
641
+ : pool.rows;
622
642
  agentsInPool = distinct(rows.map((r) => r.agent));
623
643
  devicesInPool = distinct(rows.map((r) => r.machine ?? self));
624
644
  // Both ends of the lineage seed the cycle: teams a row spawned, and teams a
@@ -14,7 +14,8 @@ import { discoverSessions } from '../lib/session/discover.js';
14
14
  import { filterTeamSessions } from '../lib/session/team-filter.js';
15
15
  import { multiItemPicker, itemPicker } from '../lib/picker.js';
16
16
  import { buildPreview } from './sessions-picker.js';
17
- import { filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, buildSessionRecoveryCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
17
+ import { formatPickerLabel, pickerColumnsFor, buildSessionRecoveryCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
18
+ import { sessionMatchesQuery } from './sessions-browser.js';
18
19
  import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
19
20
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
20
21
  import { setHelpSections } from '../lib/help.js';
@@ -111,7 +112,7 @@ async function sessionsResumeAction(query, options) {
111
112
  chosen = await multiItemPicker({
112
113
  message: 'Select sessions to resume:',
113
114
  items: sessions,
114
- filter: (q) => (q.trim() ? filterSessionsByQuery(sessions, q) : sessions),
115
+ filter: (q) => (q.trim() ? sessions.filter((s) => sessionMatchesQuery(s, q)) : sessions),
115
116
  labelFor: (s, q) => formatPickerLabel(s, q, cols),
116
117
  keyFor: (s) => s.id,
117
118
  buildPreview,
@@ -131,7 +131,7 @@ export declare function formatActiveRowDescription(s: ActiveSession): string;
131
131
  */
132
132
  export declare function indexActiveBySessionId(active: ActiveSession[]): Map<string, ActiveSession>;
133
133
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
134
- type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber'>;
134
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName'>;
135
135
  /**
136
136
  * Backfill display-only fields onto live rows from the indexed SessionMeta, by
137
137
  * full session id (RUSH-2205). A running process reports no agent version, and a
@@ -141,6 +141,8 @@ type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticke
141
141
  * the DB read that builds `metaById` is the caller's concern.
142
142
  */
143
143
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
144
+ /** Add indexed display metadata to active rows for non-renderer consumers. */
145
+ export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
144
146
  /**
145
147
  * The live decoration for a listing row: a status glyph and the latest-turn
146
148
  * preview, when the session is still running. `●` running / `◐` waiting on the
@@ -184,6 +186,16 @@ export declare function liveStatusWord(a: ActiveSession | undefined): string;
184
186
  * stays excluded rather than inventing a human who can answer.
185
187
  */
186
188
  export declare function isAwaitingUser(s: ActiveSession): boolean;
189
+ /**
190
+ * Whether a row belongs in an unqualified `--active` view.
191
+ *
192
+ * The live registry deliberately retains terminally-dead rows long enough for
193
+ * `--closed` / `--crashed` and recovery to find them. Presence in that registry
194
+ * therefore is not, by itself, evidence that a session is still active.
195
+ * Explicit lifecycle filters bypass this predicate and select those retained
196
+ * rows through {@link matchesLiveStatus} instead.
197
+ */
198
+ export declare function isRunningLiveSession(s: ActiveSession): boolean;
187
199
  /**
188
200
  * The tracker/PR ref for a session's dedicated column: the ticket id when known,
189
201
  * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
@@ -57,6 +57,7 @@ import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
57
57
  import { isFavorite, listFavorites } from '../lib/session/favorites.js';
58
58
  import { registerGoCommand } from './go.js';
59
59
  import { registerFocusCommand } from './focus.js';
60
+ import { registerReconnectCommand } from './reconnect.js';
60
61
  import { registerDetachCommand } from './detach.js';
61
62
  import { registerAttachCommand } from './attach.js';
62
63
  import { registerSessionsInjectCommand } from './sessions-inject.js';
@@ -66,6 +67,7 @@ import { registerSessionsImportCommand } from './sessions-import.js';
66
67
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
67
68
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
68
69
  import { registerSessionsStatsCommand } from './sessions-stats.js';
70
+ import { registerSessionsInsightsCommand } from './insights.js';
69
71
  import { registerSessionsOptimizeCommand } from './sessions-optimize.js';
70
72
  import { runBrowserSessions } from '../lib/browser/sessions-list.js';
71
73
  import { countToolProgramOccurrences, parseToolProgramCountClause, readToolIndexCoverage, searchToolCalls, TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, serializeToolSearchEnvelope, toolSearchRemoteReceiveBudget, } from '../lib/session/tool-index.js';
@@ -411,6 +413,10 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
411
413
  if (!Number.isNaN(ts))
412
414
  s.startedAtMs = ts;
413
415
  }
416
+ if (!s.origin && m.origin)
417
+ s.origin = m.origin;
418
+ if (!s.routineName && m.routineName)
419
+ s.routineName = m.routineName;
414
420
  }
415
421
  }
416
422
  /**
@@ -435,6 +441,10 @@ function loadBackfillMetaFor(sessions) {
435
441
  }
436
442
  return byId;
437
443
  }
444
+ /** Add indexed display metadata to active rows for non-renderer consumers. */
445
+ export function backfillActiveRowsFromIndex(sessions) {
446
+ backfillActiveRowsFromMeta(sessions, loadBackfillMetaFor(sessions));
447
+ }
438
448
  /**
439
449
  * The live decoration for a listing row: a status glyph and the latest-turn
440
450
  * preview, when the session is still running. `●` running / `◐` waiting on the
@@ -527,6 +537,20 @@ export function isAwaitingUser(s) {
527
537
  return false;
528
538
  return s.status === 'input_required' || s.activity === 'waiting_input';
529
539
  }
540
+ /**
541
+ * Whether a row belongs in an unqualified `--active` view.
542
+ *
543
+ * The live registry deliberately retains terminally-dead rows long enough for
544
+ * `--closed` / `--crashed` and recovery to find them. Presence in that registry
545
+ * therefore is not, by itself, evidence that a session is still active.
546
+ * Explicit lifecycle filters bypass this predicate and select those retained
547
+ * rows through {@link matchesLiveStatus} instead.
548
+ */
549
+ export function isRunningLiveSession(s) {
550
+ if (s.status === 'closed' || s.status === 'crashed')
551
+ return false;
552
+ return s.pidAlive !== false;
553
+ }
530
554
  /** Width of the live status column — `crashed` is the longest word it renders. */
531
555
  const LIVE_STATUS_W = 8;
532
556
  /** The colored, space-padded status cell for a listing row (empty when not live). */
@@ -1275,14 +1299,14 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1275
1299
  // gate: exit non-zero when the union contains a session awaiting the user.
1276
1300
  const statusFiltered = opts.statuses?.length
1277
1301
  ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1278
- : merged;
1302
+ : merged.filter(isRunningLiveSession);
1279
1303
  const sessions = statusFiltered;
1280
1304
  // Backfill agent version + ticket/PR/label/created onto the live rows from the
1281
1305
  // historical index (RUSH-2205) — a running process reports none of these, and
1282
1306
  // an orphan row usually lacks them. Done before both the JSON and human paths
1283
1307
  // so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
1284
1308
  // transcripts sync across the fleet, so a remote row resolves from the local DB.
1285
- backfillActiveRowsFromMeta(sessions, loadBackfillMetaFor(sessions));
1309
+ backfillActiveRowsFromIndex(sessions);
1286
1310
  if (asJson) {
1287
1311
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1288
1312
  // is how a consumer distinguishes a session someone is looking at from one
@@ -4060,6 +4084,7 @@ export function registerSessionsCommands(program) {
4060
4084
  registerSessionsFavoriteCommand(sessionsCmd);
4061
4085
  registerGoCommand(sessionsCmd);
4062
4086
  registerFocusCommand(sessionsCmd);
4087
+ registerReconnectCommand(sessionsCmd);
4063
4088
  registerDetachCommand(sessionsCmd);
4064
4089
  registerAttachCommand(sessionsCmd);
4065
4090
  registerSessionsInjectCommand(sessionsCmd);
@@ -4070,6 +4095,7 @@ export function registerSessionsCommands(program) {
4070
4095
  registerSessionsMigrationsCommand(sessionsCmd);
4071
4096
  registerSessionsBackfillCommand(sessionsCmd);
4072
4097
  registerSessionsStatsCommand(sessionsCmd);
4098
+ registerSessionsInsightsCommand(sessionsCmd);
4073
4099
  registerSessionsOptimizeCommand(sessionsCmd);
4074
4100
  // Observe-umbrella alias (Phase 3): roster → sessions --active.
4075
4101
  registerSessionsObserveAliases(program);
@@ -15,6 +15,7 @@ interface SetupSecretsOptions {
15
15
  iUnderstand?: boolean;
16
16
  force?: boolean;
17
17
  }
18
+ export declare function setupSecretsPrefsPath(): string;
18
19
  /** The wizard's `--policy` shares the canonical parser with `agents secrets
19
20
  * policy`, so it accepts `hold` (and the `daily`/`session` aliases) rather than
20
21
  * carrying its own copy that knows only the retired vocabulary. `parsePolicyOpt`
@@ -15,7 +15,7 @@ import { parsePolicyOpt } from './secrets.js';
15
15
  function defaultBackendForPlatform(platform = process.platform) {
16
16
  return platform === 'darwin' ? 'keychain' : 'file';
17
17
  }
18
- function setupSecretsPrefsPath() {
18
+ export function setupSecretsPrefsPath() {
19
19
  return path.join(getHistoryDir(), 'setup', 'secrets.json');
20
20
  }
21
21
  function parseBackend(raw) {