@phnx-labs/agents-cli 1.19.1 → 1.20.0

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 (109) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +70 -10
  3. package/dist/commands/browser.js +88 -16
  4. package/dist/commands/cli.d.ts +14 -0
  5. package/dist/commands/cli.js +244 -0
  6. package/dist/commands/commands.js +3 -3
  7. package/dist/commands/computer.js +18 -1
  8. package/dist/commands/doctor.d.ts +1 -1
  9. package/dist/commands/doctor.js +2 -2
  10. package/dist/commands/exec.js +3 -3
  11. package/dist/commands/factory.d.ts +3 -14
  12. package/dist/commands/factory.js +3 -3
  13. package/dist/commands/hooks.js +3 -3
  14. package/dist/commands/mcp.js +29 -0
  15. package/dist/commands/plugins.js +11 -4
  16. package/dist/commands/profiles.js +1 -1
  17. package/dist/commands/prune.js +39 -160
  18. package/dist/commands/pull.js +56 -3
  19. package/dist/commands/routines.js +106 -13
  20. package/dist/commands/secrets.js +6 -8
  21. package/dist/commands/sessions.d.ts +36 -7
  22. package/dist/commands/sessions.js +130 -53
  23. package/dist/commands/setup.d.ts +1 -0
  24. package/dist/commands/setup.js +37 -28
  25. package/dist/commands/skills.js +3 -3
  26. package/dist/commands/teams.js +13 -0
  27. package/dist/commands/versions.d.ts +4 -3
  28. package/dist/commands/versions.js +147 -124
  29. package/dist/commands/view.js +12 -12
  30. package/dist/index.js +34 -6
  31. package/dist/lib/acp/harnesses.js +8 -0
  32. package/dist/lib/agents.js +162 -9
  33. package/dist/lib/browser/cdp.d.ts +8 -1
  34. package/dist/lib/browser/cdp.js +40 -3
  35. package/dist/lib/browser/chrome.d.ts +13 -0
  36. package/dist/lib/browser/chrome.js +42 -3
  37. package/dist/lib/browser/domain-skills.d.ts +51 -0
  38. package/dist/lib/browser/domain-skills.js +157 -0
  39. package/dist/lib/browser/drivers/local.js +45 -4
  40. package/dist/lib/browser/drivers/ssh.js +1 -1
  41. package/dist/lib/browser/ipc.d.ts +8 -1
  42. package/dist/lib/browser/ipc.js +37 -28
  43. package/dist/lib/browser/profiles.d.ts +13 -0
  44. package/dist/lib/browser/profiles.js +41 -1
  45. package/dist/lib/browser/service.d.ts +3 -0
  46. package/dist/lib/browser/service.js +21 -5
  47. package/dist/lib/browser/types.d.ts +7 -0
  48. package/dist/lib/cli-resources.d.ts +109 -0
  49. package/dist/lib/cli-resources.js +255 -0
  50. package/dist/lib/cloud/rush.js +5 -5
  51. package/dist/lib/command-skills.js +0 -2
  52. package/dist/lib/computer-rpc.d.ts +3 -0
  53. package/dist/lib/computer-rpc.js +53 -0
  54. package/dist/lib/daemon.js +20 -0
  55. package/dist/lib/exec.d.ts +3 -2
  56. package/dist/lib/exec.js +62 -6
  57. package/dist/lib/hooks.js +182 -0
  58. package/dist/lib/mcp.js +6 -0
  59. package/dist/lib/migrate.js +1 -1
  60. package/dist/lib/overdue.d.ts +26 -0
  61. package/dist/lib/overdue.js +101 -0
  62. package/dist/lib/permissions.js +5 -1
  63. package/dist/lib/plugin-marketplace.js +1 -1
  64. package/dist/lib/profiles-presets.js +37 -0
  65. package/dist/lib/registry.d.ts +18 -0
  66. package/dist/lib/registry.js +44 -0
  67. package/dist/lib/resources/mcp.js +43 -1
  68. package/dist/lib/resources/types.d.ts +1 -1
  69. package/dist/lib/resources.d.ts +1 -1
  70. package/dist/lib/rotate.js +10 -4
  71. package/dist/lib/routines-format.d.ts +35 -0
  72. package/dist/lib/routines-format.js +173 -0
  73. package/dist/lib/routines.d.ts +7 -1
  74. package/dist/lib/routines.js +32 -12
  75. package/dist/lib/runner.js +19 -5
  76. package/dist/lib/scheduler.js +8 -1
  77. package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/CodeResources +0 -0
  78. package/dist/lib/secrets/{AgentsKeychain.app/Contents/Info.plist → Agents CLI.app/Contents/Info.plist } +4 -2
  79. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  80. package/dist/lib/secrets/bundles.d.ts +33 -2
  81. package/dist/lib/secrets/bundles.js +249 -26
  82. package/dist/lib/secrets/index.d.ts +10 -1
  83. package/dist/lib/secrets/index.js +143 -48
  84. package/dist/lib/session/active.d.ts +8 -0
  85. package/dist/lib/session/active.js +3 -2
  86. package/dist/lib/session/db.d.ts +10 -4
  87. package/dist/lib/session/db.js +16 -16
  88. package/dist/lib/session/parse.d.ts +1 -0
  89. package/dist/lib/session/parse.js +44 -0
  90. package/dist/lib/session/types.d.ts +1 -1
  91. package/dist/lib/session/types.js +1 -1
  92. package/dist/lib/shims.d.ts +6 -2
  93. package/dist/lib/shims.js +88 -10
  94. package/dist/lib/state.d.ts +0 -1
  95. package/dist/lib/state.js +2 -15
  96. package/dist/lib/teams/agents.js +1 -1
  97. package/dist/lib/teams/parsers.d.ts +1 -1
  98. package/dist/lib/teams/parsers.js +153 -3
  99. package/dist/lib/teams/summarizer.js +18 -2
  100. package/dist/lib/teams/worktree.js +14 -3
  101. package/dist/lib/types.d.ts +7 -4
  102. package/dist/lib/types.js +6 -3
  103. package/dist/lib/versions.d.ts +10 -2
  104. package/dist/lib/versions.js +227 -35
  105. package/package.json +9 -9
  106. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  107. package/npm-shrinkwrap.json +0 -3162
  108. /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/_CodeSignature/CodeResources +0 -0
  109. /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/embedded.provisionprofile +0 -0
@@ -14,10 +14,11 @@ import { isGitRepo, pullRepo, isSystemRepoOrigin, } from '../lib/git.js';
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import { installVersion, listInstalledVersions, getGlobalDefault, setGlobalDefault, getVersionHomePath, syncResourcesToVersion, getAvailableResources, getActuallySyncedResources, getNewResources, hasNewResources, promptNewResourceSelection, promptResourceSelection, resolveConfiguredAgentTargets, } from '../lib/versions.js';
17
+ import { listCliStatus, installCli, describeMethod, selectInstallMethod, } from '../lib/cli-resources.js';
17
18
  import { ensureShimCurrent, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
18
19
  import { parseHookManifest, registerHooksToSettings } from '../lib/hooks.js';
19
20
  import { setHelpSections } from '../lib/help.js';
20
- import { select } from '@inquirer/prompts';
21
+ import { select, confirm } from '@inquirer/prompts';
21
22
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
22
23
  /**
23
24
  * Old repo layout stored promptcuts under claude/promptcuts.yaml (agent-scoped).
@@ -244,10 +245,10 @@ export function registerPullCommand(program) {
244
245
  if (userSelection)
245
246
  selection = userSelection;
246
247
  }
247
- else if (hasNewResources(newResources, agentId)) {
248
+ else if (hasNewResources(newResources, agentId, defaultVer)) {
248
249
  // Has synced before, but NEW items available
249
250
  console.log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
250
- const userSelection = await promptNewResourceSelection(agentId, newResources);
251
+ const userSelection = await promptNewResourceSelection(agentId, newResources, defaultVer);
251
252
  if (userSelection)
252
253
  selection = userSelection;
253
254
  }
@@ -366,6 +367,58 @@ export function registerPullCommand(program) {
366
367
  console.log(chalk.green(`Set ${agentLabel(agent.id)}@${version} as default`));
367
368
  }
368
369
  }
370
+ // Report (and optionally install) any declared CLIs that are missing
371
+ // from the host. Skipped under -y so non-interactive pulls don't trigger
372
+ // package-manager prompts.
373
+ try {
374
+ const { statuses, errors } = listCliStatus(process.cwd());
375
+ for (const err of errors) {
376
+ console.log(chalk.yellow(` CLI manifest parse error: ${err.file}: ${err.reason}`));
377
+ }
378
+ const missing = statuses.filter((s) => !s.installed);
379
+ if (missing.length > 0) {
380
+ console.log(chalk.bold('\nDeclared CLIs missing from this host:'));
381
+ for (const s of missing) {
382
+ const method = selectInstallMethod(s.manifest);
383
+ const action = method ? describeMethod(method) : chalk.red('no compatible install method');
384
+ console.log(` ${chalk.cyan(s.manifest.name.padEnd(20))} ${chalk.gray(action)}`);
385
+ }
386
+ console.log('');
387
+ if (!skipPrompts) {
388
+ const proceed = await confirm({ message: `Install ${missing.length} missing CLI(s) now?`, default: true });
389
+ if (proceed) {
390
+ for (const s of missing) {
391
+ console.log(chalk.bold(`\n→ ${s.manifest.name}`));
392
+ const result = installCli(s.manifest);
393
+ if (result.error) {
394
+ console.log(chalk.red(` ${result.error}`));
395
+ continue;
396
+ }
397
+ if (result.installed) {
398
+ console.log(chalk.green(` installed`));
399
+ if (s.manifest.postInstall) {
400
+ console.log(chalk.gray(s.manifest.postInstall.trim().split('\n').map((l) => ' ' + l).join('\n')));
401
+ }
402
+ }
403
+ else {
404
+ console.log(chalk.yellow(` install ran but \`${s.manifest.check}\` still fails`));
405
+ }
406
+ }
407
+ }
408
+ else {
409
+ console.log(chalk.gray(`Skipped. Run 'agents cli install' later.`));
410
+ }
411
+ }
412
+ else {
413
+ console.log(chalk.gray(`Run 'agents cli install' to install them.`));
414
+ }
415
+ }
416
+ }
417
+ catch (err) {
418
+ if (!isPromptCancelled(err)) {
419
+ console.log(chalk.yellow(`CLI install skipped: ${err.message}`));
420
+ }
421
+ }
369
422
  console.log(chalk.green('\nPull complete'));
370
423
  }
371
424
  catch (err) {
@@ -11,11 +11,13 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonPid, readDaemonLog, } from '../lib/daemon.js';
14
+ import { humanizeCron, humanizeNextRun, formatRepoLink } from '../lib/routines-format.js';
14
15
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, } from '../lib/routines.js';
15
16
  import { getRoutinesDir } from '../lib/state.js';
16
17
  import { safeJoin } from '../lib/paths.js';
17
- import { executeJob } from '../lib/runner.js';
18
+ import { executeJob, executeJobDetached } from '../lib/runner.js';
18
19
  import { JobScheduler } from '../lib/scheduler.js';
20
+ import { detectOverdueJobs } from '../lib/overdue.js';
19
21
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
20
22
  import { setHelpSections } from '../lib/help.js';
21
23
  /** Start or reload the background scheduler so newly-added jobs fire on time. */
@@ -60,7 +62,7 @@ async function pickJob(message, filter, alternatives = []) {
60
62
  message,
61
63
  choices: jobs.map((job) => ({
62
64
  value: job.name,
63
- name: `${job.name} ${chalk.gray(`(${job.agent}, ${job.schedule})`)}`,
65
+ name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule})`)}`,
64
66
  })),
65
67
  });
66
68
  }
@@ -120,18 +122,57 @@ export function registerRoutinesCommands(program) {
120
122
  }
121
123
  const scheduler = new JobScheduler(async () => { });
122
124
  scheduler.loadAll();
125
+ // Build a quick lookup: which jobs are currently overdue?
126
+ const overdueSet = new Set();
127
+ try {
128
+ for (const j of detectOverdueJobs())
129
+ overdueSet.add(j.name);
130
+ }
131
+ catch {
132
+ // Best-effort indicator; never block the list on detection errors.
133
+ }
123
134
  console.log(chalk.bold('Scheduled Jobs\n'));
124
- const header = ` ${'Name'.padEnd(24)} ${'Agent'.padEnd(10)} ${'Schedule'.padEnd(20)} ${'Enabled'.padEnd(10)} ${'Next Run'.padEnd(24)} ${'Last Status'}`;
135
+ // OSC 8 hyperlink helper renders as a clickable link in supporting terminals.
136
+ // In terminals that do not support OSC 8 the escape sequences are ignored and
137
+ // the label is displayed as plain text.
138
+ const link = (label, url) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
139
+ const now = new Date();
140
+ const NAME_W = 24;
141
+ const AGENT_W = 10;
142
+ const REPO_W = 24;
143
+ const SCHED_W = 22;
144
+ const ENABLED_W = 10;
145
+ const NEXT_W = 22;
146
+ const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
125
147
  console.log(chalk.gray(header));
126
- console.log(chalk.gray(' ' + '-'.repeat(110)));
148
+ console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
127
149
  for (const job of jobs) {
128
150
  const nextRun = scheduler.getNextRun(job.name);
129
- const nextStr = nextRun ? nextRun.toLocaleString() : '-';
151
+ const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
152
+ const schedStr = humanizeCron(job.schedule, job.timezone);
130
153
  const latestRun = getLatestRun(job.name);
131
154
  const lastStatus = latestRun?.status || '-';
155
+ const repoInfo = formatRepoLink(job.repo);
156
+ const repoCell = link(repoInfo.display, repoInfo.href);
157
+ // Pad based on the display string, not the raw cell (which may include escape codes).
158
+ const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
132
159
  const enabledStr = job.enabled ? chalk.green('yes') : chalk.gray('no');
133
- const statusColor = lastStatus === 'completed' ? chalk.green : lastStatus === 'failed' ? chalk.red : lastStatus === 'timeout' ? chalk.yellow : chalk.gray;
134
- console.log(` ${chalk.cyan(job.name.padEnd(24))} ${job.agent.padEnd(10)} ${job.schedule.padEnd(20)} ${enabledStr.padEnd(10 + 10)} ${chalk.gray(nextStr.padEnd(24))} ${statusColor(lastStatus)}`);
160
+ // chalk adds escape codes; pad the raw word and let chalk wrap it.
161
+ const enabledWord = job.enabled ? 'yes' : 'no';
162
+ const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
163
+ const statusColor = lastStatus === 'completed' ? chalk.green
164
+ : lastStatus === 'failed' ? chalk.red
165
+ : lastStatus === 'timeout' ? chalk.yellow
166
+ : chalk.gray;
167
+ const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
168
+ const agentLabelPadded = job.workflow
169
+ ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
170
+ : (job.agent || '').padEnd(10);
171
+ console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
172
+ }
173
+ if (overdueSet.size > 0) {
174
+ console.log();
175
+ console.log(chalk.yellow(` ${overdueSet.size} routine(s) overdue — catch up with: agents routines catchup`));
135
176
  }
136
177
  scheduler.stopAll();
137
178
  console.log();
@@ -141,16 +182,17 @@ export function registerRoutinesCommands(program) {
141
182
  .description('Create a new routine from a YAML file or inline flags. Starts the scheduler automatically if it is not already running.')
142
183
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
143
184
  .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
185
+ .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
144
186
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
145
187
  .option('-m, --mode <mode>', 'Execution mode: plan (read-only) or edit (can write files)', 'plan')
146
188
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
147
- .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 30m, 2h)', '30m')
189
+ .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
148
190
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
149
191
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
150
192
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
151
193
  .action(async (nameOrPath, options) => {
152
194
  // Check if inline mode (has flags) or file mode
153
- const hasInlineFlags = options.schedule || options.agent || options.prompt || options.at;
195
+ const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at;
154
196
  if (hasInlineFlags) {
155
197
  // Inline mode: create job from flags
156
198
  if (!nameOrPath) {
@@ -158,6 +200,11 @@ export function registerRoutinesCommands(program) {
158
200
  console.log(chalk.gray('Usage: agents routines add <name> --schedule "..." --agent <agent> --prompt "..."'));
159
201
  process.exit(1);
160
202
  }
203
+ // Validate mutually exclusive --agent / --workflow
204
+ if (options.agent && options.workflow) {
205
+ console.log(chalk.red('--agent and --workflow are mutually exclusive; specify exactly one'));
206
+ process.exit(1);
207
+ }
161
208
  let schedule = options.schedule;
162
209
  let runOnce = false;
163
210
  // Handle --at for one-shot jobs
@@ -175,8 +222,8 @@ export function registerRoutinesCommands(program) {
175
222
  console.log(chalk.red('Schedule is required (use --schedule or --at)'));
176
223
  process.exit(1);
177
224
  }
178
- if (!options.agent) {
179
- console.log(chalk.red('Agent is required (use --agent)'));
225
+ if (!options.agent && !options.workflow) {
226
+ console.log(chalk.red('An agent or workflow is required (use --agent or --workflow)'));
180
227
  process.exit(1);
181
228
  }
182
229
  if (!options.prompt) {
@@ -187,6 +234,7 @@ export function registerRoutinesCommands(program) {
187
234
  name: nameOrPath,
188
235
  schedule,
189
236
  agent: options.agent,
237
+ ...(options.workflow ? { workflow: options.workflow } : {}),
190
238
  mode: options.mode,
191
239
  effort: options.effort,
192
240
  timeout: options.timeout,
@@ -245,7 +293,7 @@ export function registerRoutinesCommands(program) {
245
293
  const config = {
246
294
  mode: 'plan',
247
295
  effort: 'auto',
248
- timeout: '30m',
296
+ timeout: '10m',
249
297
  enabled: true,
250
298
  ...parsed,
251
299
  };
@@ -387,7 +435,8 @@ export function registerRoutinesCommands(program) {
387
435
  console.log(chalk.red(`Job '${name}' not found`));
388
436
  process.exit(1);
389
437
  }
390
- console.log(chalk.bold(`Running job '${name}' (agent: ${job.agent}, mode: ${job.mode})\n`));
438
+ const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
439
+ console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
391
440
  const spinner = ora('Executing...').start();
392
441
  try {
393
442
  const result = await executeJob(job);
@@ -413,6 +462,50 @@ export function registerRoutinesCommands(program) {
413
462
  process.exit(1);
414
463
  }
415
464
  });
465
+ routinesCmd
466
+ .command('catchup')
467
+ .description('Run any routines that missed their last scheduled fire (e.g. because your laptop was off). Detached — runs in the background under the scheduler.')
468
+ .option('--dry-run', 'List overdue routines without running them')
469
+ .action(async (options) => {
470
+ const overdue = detectOverdueJobs();
471
+ if (overdue.length === 0) {
472
+ console.log(chalk.gray('No overdue routines.'));
473
+ return;
474
+ }
475
+ console.log(chalk.bold(`${overdue.length} overdue routine(s):\n`));
476
+ for (const job of overdue) {
477
+ const last = job.lastRanAt ? job.lastRanAt.toLocaleString() : 'never';
478
+ console.log(` ${chalk.cyan(job.name)} — missed ${chalk.gray(job.expectedAt.toLocaleString())}, last ran ${chalk.gray(last)}`);
479
+ }
480
+ if (options.dryRun) {
481
+ console.log(chalk.gray('\n(dry run — no jobs triggered)'));
482
+ return;
483
+ }
484
+ // Need the daemon alive so spawned jobs are monitored and meta.json is
485
+ // finalized. Start it if it isn't already running.
486
+ if (!isDaemonRunning()) {
487
+ const started = startDaemon();
488
+ if (started.pid) {
489
+ console.log(chalk.gray(`\nStarted scheduler (PID: ${started.pid}) so catchup runs are monitored.`));
490
+ }
491
+ }
492
+ console.log(chalk.bold('\nTriggering catchup runs...'));
493
+ for (const job of overdue) {
494
+ const config = readJob(job.name);
495
+ if (!config) {
496
+ console.log(` ${job.name} → ${chalk.red('config not found')}`);
497
+ continue;
498
+ }
499
+ try {
500
+ const meta = await executeJobDetached(config);
501
+ console.log(` ${job.name} → ${chalk.green('started')} (run: ${meta.runId}, PID: ${meta.pid ?? 'n/a'})`);
502
+ }
503
+ catch (err) {
504
+ console.log(` ${job.name} → ${chalk.red('failed to start')}: ${err.message}`);
505
+ }
506
+ }
507
+ console.log(chalk.gray('\nTrack progress with: agents routines runs <name>'));
508
+ });
416
509
  routinesCmd
417
510
  .command('logs [name]')
418
511
  .description('Read stdout from the most recent execution. Use --run to see a specific past run.')
@@ -873,13 +873,12 @@ Examples:
873
873
  .option('--force', 'Overwrite existing 1Password items (used with --to-1password)')
874
874
  .action(async (bundleName, opts) => {
875
875
  try {
876
- const { resolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
876
+ const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
877
877
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
878
- const bundle = readBundle(resolvedBundleName);
879
878
  if (opts.to1password) {
880
879
  assertOpAvailable();
881
880
  const vault = await resolveVault(opts.vault);
882
- const env = resolveBundleEnv(bundle);
881
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}` });
883
882
  let created = 0;
884
883
  let overwritten = 0;
885
884
  let skipped = 0;
@@ -913,7 +912,7 @@ Examples:
913
912
  console.error(chalk.red('export to a TTY requires --plaintext (prevents shoulder-surfing).'));
914
913
  process.exit(1);
915
914
  }
916
- const env = resolveBundleEnv(bundle);
915
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `export to shell` });
917
916
  const prefix = bundleToEnvPrefix(resolvedBundleName);
918
917
  for (const [k, v] of Object.entries(env)) {
919
918
  const exportKey = isReservedEnvName(k) ? `${prefix}_${k}` : k;
@@ -939,11 +938,10 @@ Examples:
939
938
  console.error(chalk.red('Usage: agents secrets exec <bundle> -- <command...>'));
940
939
  process.exit(1);
941
940
  }
942
- const { resolveBundleEnv } = await import('../lib/secrets/bundles.js');
943
- const bundle = readBundle(bundleName);
944
- const secretEnv = resolveBundleEnv(bundle);
945
- const { spawn } = await import('child_process');
941
+ const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
946
942
  const [cmd, ...args] = commandParts;
943
+ const { env: secretEnv } = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` });
944
+ const { spawn } = await import('child_process');
947
945
  const proc = spawn(cmd, args, {
948
946
  stdio: 'inherit',
949
947
  env: { ...process.env, ...secretEnv },
@@ -1,16 +1,45 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { SessionMeta } from '../lib/session/types.js';
3
+ import { type ActiveSession } from '../lib/session/active.js';
4
+ /** Grouped + sorted view of active sessions for the --active renderer. */
5
+ export interface ActiveSessionsLayout {
6
+ workspaces: Array<{
7
+ /** Internal grouping key — `__cloud__`, `__unknown__`, or the cwd. */
8
+ key: string;
9
+ /** Sessions in this workspace, both windowed and flat (preserves total count). */
10
+ total: number;
11
+ /** Terminals grouped by IDE window (sorted by oldest startedAtMs). */
12
+ windows: Array<{
13
+ windowId: string;
14
+ sessions: ActiveSession[];
15
+ }>;
16
+ /** Everything else in this workspace: cloud, teams, headless, terminals without a windowId. */
17
+ flat: ActiveSession[];
18
+ }>;
19
+ }
20
+ /**
21
+ * Group sessions by workspace, then split each workspace into IDE-window
22
+ * sub-groups + a flat bucket. Pure function — no I/O — so the renderer's
23
+ * grouping rules can be tested without mocking the session scanner.
24
+ *
25
+ * Sort order:
26
+ * - workspaces: by session count descending, then key ascending
27
+ * - windows within a workspace: by oldest startedAtMs ascending
28
+ * - sessions within a window/flat bucket: input order preserved
29
+ */
30
+ export declare function groupActiveSessions(sessions: ActiveSession[]): ActiveSessionsLayout;
3
31
  /**
4
32
  * Build the shell command that resumes a picked session.
5
33
  *
6
- * Cross-version handoff: when the session was created on a different version
7
- * than the one the shim will launch (activeVersion), the session file lives in
8
- * the other version's isolated home and `--resume <id>` would silently fail to
9
- * find it. Fall back to a fresh session seeded with `/continue <id>`, which is
10
- * wired (~/.claude/commands/continue.md) to read the prior transcript via
11
- * `agents sessions <id>` that reader is version-agnostic.
34
+ * When the session's originating version is known, uses the version-pinned
35
+ * binary (e.g. `claude@2.1.138`) so the resume always runs in the same
36
+ * isolated HOME where the JSONL was written regardless of which version is
37
+ * currently the default. Falls back to the bare shim when version is unknown.
38
+ *
39
+ * If the versioned binary is missing (version was removed), the ENOENT
40
+ * handler in handlePickedSession retries via buildFallbackCommand.
12
41
  */
13
- export declare function buildResumeCommand(session: SessionMeta, activeVersion?: string): string[] | null;
42
+ export declare function buildResumeCommand(session: SessionMeta): string[] | null;
14
43
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
15
44
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
16
45
  /** Register the `agents sessions` command with all its options and help text. */
@@ -22,7 +22,7 @@ import { parseSession } from '../lib/session/parse.js';
22
22
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
23
23
  import { renderMarkdown } from '../lib/markdown.js';
24
24
  import { colorAgent, resolveAgentName } from '../lib/agents.js';
25
- import { resolveVersion, resolveVersionAliasLoose } from '../lib/versions.js';
25
+ import { resolveVersionAliasLoose } from '../lib/versions.js';
26
26
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
27
27
  import { sessionPicker } from './sessions-picker.js';
28
28
  import { setHelpSections } from '../lib/help.js';
@@ -181,18 +181,45 @@ function buildSessionDescription(s) {
181
181
  return s.topic;
182
182
  return '';
183
183
  }
184
- /** Render the unified active-session view. */
185
- async function renderActiveSessions(asJson) {
186
- const sessions = await getActiveSessions();
187
- if (asJson) {
188
- process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
189
- return;
190
- }
191
- if (sessions.length === 0) {
192
- console.log(chalk.gray('No active agent sessions.'));
193
- return;
194
- }
195
- // Group sessions by workspace (cwd), with cloud/undefined grouped separately
184
+ /**
185
+ * Render a single agent-session row inside an already-printed group header.
186
+ * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a
187
+ * window sub-group).
188
+ */
189
+ function printActiveRow(s, indent) {
190
+ const kindCol = colorAgent(s.kind)(padRight(truncate(s.kind, 8), 9));
191
+ const hostCol = chalk.gray(padRight(truncate(s.host ?? '-', 8), 9));
192
+ const statusCol = statusColor(s.status)(padRight(truncate(s.status, 7), 8));
193
+ const pidCol = chalk.yellow(padRight(s.pid ? String(s.pid) : '-', 7));
194
+ const desc = buildSessionDescription(s);
195
+ console.log(indent +
196
+ pidCol +
197
+ kindCol +
198
+ hostCol +
199
+ statusCol +
200
+ chalk.white(truncate(desc || '-', 50)));
201
+ }
202
+ /**
203
+ * Short label for an IDE window. The slice key in live-terminals.json is
204
+ * `${vscode.env.sessionId}-${ext-host pid}`; the trailing pid is the cheap
205
+ * stable disambiguator. We surface it as `ext-pid` so two windows on the
206
+ * same repo are visibly different.
207
+ */
208
+ function shortWindowLabel(windowId) {
209
+ const m = windowId.match(/-(\d+)$/);
210
+ return m ? `ext-pid ${m[1]}` : `win ${windowId.slice(0, 8)}`;
211
+ }
212
+ /**
213
+ * Group sessions by workspace, then split each workspace into IDE-window
214
+ * sub-groups + a flat bucket. Pure function — no I/O — so the renderer's
215
+ * grouping rules can be tested without mocking the session scanner.
216
+ *
217
+ * Sort order:
218
+ * - workspaces: by session count descending, then key ascending
219
+ * - windows within a workspace: by oldest startedAtMs ascending
220
+ * - sessions within a window/flat bucket: input order preserved
221
+ */
222
+ export function groupActiveSessions(sessions) {
196
223
  const byWorkspace = new Map();
197
224
  for (const s of sessions) {
198
225
  const key = s.cwd ?? (s.context === 'cloud' ? '__cloud__' : '__unknown__');
@@ -200,7 +227,6 @@ async function renderActiveSessions(asJson) {
200
227
  list.push(s);
201
228
  byWorkspace.set(key, list);
202
229
  }
203
- // Sort workspaces: most sessions first, then alphabetically
204
230
  const sortedKeys = Array.from(byWorkspace.keys()).sort((a, b) => {
205
231
  const aCount = byWorkspace.get(a).length;
206
232
  const bCount = byWorkspace.get(b).length;
@@ -208,33 +234,71 @@ async function renderActiveSessions(asJson) {
208
234
  return bCount - aCount;
209
235
  return a.localeCompare(b);
210
236
  });
211
- let first = true;
212
- for (const key of sortedKeys) {
237
+ const workspaces = sortedKeys.map((key) => {
213
238
  const group = byWorkspace.get(key);
239
+ const windowedSessions = [];
240
+ const flat = [];
241
+ for (const s of group) {
242
+ if (s.context === 'terminal' && s.windowId)
243
+ windowedSessions.push(s);
244
+ else
245
+ flat.push(s);
246
+ }
247
+ const byWindow = new Map();
248
+ for (const s of windowedSessions) {
249
+ const list = byWindow.get(s.windowId) || [];
250
+ list.push(s);
251
+ byWindow.set(s.windowId, list);
252
+ }
253
+ const windowKeys = Array.from(byWindow.keys()).sort((a, b) => {
254
+ const aStart = Math.min(...byWindow.get(a).map(s => s.startedAtMs ?? Infinity));
255
+ const bStart = Math.min(...byWindow.get(b).map(s => s.startedAtMs ?? Infinity));
256
+ return aStart - bStart;
257
+ });
258
+ return {
259
+ key,
260
+ total: group.length,
261
+ windows: windowKeys.map((wid) => ({ windowId: wid, sessions: byWindow.get(wid) })),
262
+ flat,
263
+ };
264
+ });
265
+ return { workspaces };
266
+ }
267
+ /** Render the unified active-session view. */
268
+ async function renderActiveSessions(asJson) {
269
+ const sessions = await getActiveSessions();
270
+ if (asJson) {
271
+ process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
272
+ return;
273
+ }
274
+ if (sessions.length === 0) {
275
+ console.log(chalk.gray('No active agent sessions.'));
276
+ return;
277
+ }
278
+ const layout = groupActiveSessions(sessions);
279
+ let first = true;
280
+ for (const ws of layout.workspaces) {
214
281
  if (!first)
215
282
  console.log();
216
283
  first = false;
217
284
  // Print workspace header
218
- const header = key === '__cloud__'
285
+ const header = ws.key === '__cloud__'
219
286
  ? chalk.magenta.bold('cloud')
220
- : key === '__unknown__'
287
+ : ws.key === '__unknown__'
221
288
  ? chalk.gray.bold('unknown')
222
- : chalk.cyan.bold(shortCwd(key));
223
- console.log(`${header} ${chalk.gray(`(${group.length})`)}`);
224
- // Print each session in this workspace
225
- for (const s of group) {
226
- const kindCol = colorAgent(s.kind)(padRight(truncate(s.kind, 8), 9));
227
- const hostCol = chalk.gray(padRight(truncate(s.host ?? '-', 8), 9));
228
- const statusCol = statusColor(s.status)(padRight(truncate(s.status, 7), 8));
229
- const pidCol = chalk.yellow(padRight(s.pid ? String(s.pid) : '-', 7));
230
- const desc = buildSessionDescription(s);
231
- console.log(' ' +
232
- pidCol +
233
- kindCol +
234
- hostCol +
235
- statusCol +
236
- chalk.white(truncate(desc || '-', 50)));
289
+ : chalk.cyan.bold(shortCwd(ws.key));
290
+ console.log(`${header} ${chalk.gray(`(${ws.total})`)}`);
291
+ for (const win of ws.windows) {
292
+ // Host is per-process, but every terminal in the same IDE window shares
293
+ // an ancestor — take the first non-empty host as the window's label.
294
+ const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
295
+ const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
296
+ console.log(' ' + winHeader);
297
+ for (const s of win.sessions)
298
+ printActiveRow(s, ' ');
237
299
  }
300
+ for (const s of ws.flat)
301
+ printActiveRow(s, ' ');
238
302
  }
239
303
  const runningCount = sessions.filter(s => s.status === 'running').length;
240
304
  const idleCount = sessions.filter(s => s.status === 'idle').length;
@@ -613,18 +677,12 @@ async function handlePickedSession(picked) {
613
677
  const cwd = picked.session.cwd && fs.existsSync(picked.session.cwd)
614
678
  ? picked.session.cwd
615
679
  : process.cwd();
616
- const activeVersion = resolveVersion(picked.session.agent, cwd) ?? undefined;
617
- const resume = buildResumeCommand(picked.session, activeVersion);
680
+ const resume = buildResumeCommand(picked.session);
618
681
  if (!resume) {
619
682
  console.log(chalk.yellow(`Resume is not supported for ${picked.session.agent} sessions yet. Showing summary instead.`));
620
683
  await renderSession(picked.session, 'summary', {});
621
684
  return;
622
685
  }
623
- if (picked.session.version && activeVersion && picked.session.version !== activeVersion) {
624
- console.log(chalk.gray(`Cross-version handoff: session is ${picked.session.agent} ${picked.session.version}, ` +
625
- `default is ${activeVersion}. Starting fresh and passing /continue so the new agent ` +
626
- `reads the prior transcript via 'agents sessions'.`));
627
- }
628
686
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
629
687
  await new Promise((resolve) => {
630
688
  const child = spawn(resume[0], resume.slice(1), {
@@ -633,6 +691,16 @@ async function handlePickedSession(picked) {
633
691
  shell: false,
634
692
  });
635
693
  child.on('error', (err) => {
694
+ if (err.code === 'ENOENT' && picked.session.version) {
695
+ const fallback = buildFallbackCommand(picked.session);
696
+ if (fallback) {
697
+ console.log(chalk.gray(`Version ${picked.session.version} is not installed. Falling back to current version via /continue...`));
698
+ const fb = spawn(fallback[0], fallback.slice(1), { cwd, stdio: 'inherit', shell: false });
699
+ fb.on('error', (e) => { console.error(chalk.red(`Failed: ${e.message}`)); resolve(); });
700
+ fb.on('close', () => resolve());
701
+ return;
702
+ }
703
+ }
636
704
  console.error(chalk.red(`Failed to launch ${resume[0]}: ${err.message}`));
637
705
  if (err.code === 'ENOENT') {
638
706
  console.error(chalk.gray(`Make sure '${resume[0]}' is on your PATH.`));
@@ -645,23 +713,23 @@ async function handlePickedSession(picked) {
645
713
  /**
646
714
  * Build the shell command that resumes a picked session.
647
715
  *
648
- * Cross-version handoff: when the session was created on a different version
649
- * than the one the shim will launch (activeVersion), the session file lives in
650
- * the other version's isolated home and `--resume <id>` would silently fail to
651
- * find it. Fall back to a fresh session seeded with `/continue <id>`, which is
652
- * wired (~/.claude/commands/continue.md) to read the prior transcript via
653
- * `agents sessions <id>` that reader is version-agnostic.
716
+ * When the session's originating version is known, uses the version-pinned
717
+ * binary (e.g. `claude@2.1.138`) so the resume always runs in the same
718
+ * isolated HOME where the JSONL was written regardless of which version is
719
+ * currently the default. Falls back to the bare shim when version is unknown.
720
+ *
721
+ * If the versioned binary is missing (version was removed), the ENOENT
722
+ * handler in handlePickedSession retries via buildFallbackCommand.
654
723
  */
655
- export function buildResumeCommand(session, activeVersion) {
656
- const versionMismatch = !!(session.version && activeVersion && session.version !== activeVersion);
724
+ export function buildResumeCommand(session) {
657
725
  switch (session.agent) {
658
726
  case 'claude':
659
- if (versionMismatch)
660
- return ['claude', `/continue ${session.id}`];
727
+ if (session.version)
728
+ return [`claude@${session.version}`, '--resume', session.id];
661
729
  return ['claude', '--resume', session.id];
662
730
  case 'codex':
663
- if (versionMismatch)
664
- return ['codex', `/continue ${session.id}`];
731
+ if (session.version)
732
+ return [`codex@${session.version}`, 'resume', session.id];
665
733
  return ['codex', 'resume', session.id];
666
734
  case 'opencode':
667
735
  return ['opencode', '--session', session.id];
@@ -669,10 +737,19 @@ export function buildResumeCommand(session, activeVersion) {
669
737
  case 'openclaw':
670
738
  case 'rush':
671
739
  case 'hermes':
672
- // Rush and Hermes sessions are captured artifacts, not resumable locally.
740
+ case 'grok':
741
+ // Grok (and some others) sessions are captured artifacts, not resumable the same way.
673
742
  return null;
674
743
  }
675
744
  }
745
+ /** Fallback resume command when the versioned binary is unavailable (ENOENT). */
746
+ function buildFallbackCommand(session) {
747
+ switch (session.agent) {
748
+ case 'claude': return ['claude', `/continue ${session.id}`];
749
+ case 'codex': return ['codex', `/continue ${session.id}`];
750
+ default: return null;
751
+ }
752
+ }
676
753
  // ---------------------------------------------------------------------------
677
754
  // Cloud session source (--cloud)
678
755
  // ---------------------------------------------------------------------------