@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { confirm, input } from '@inquirer/prompts';
9
9
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
10
10
  import { setHelpSections } from '../lib/help.js';
11
11
  import { itemPicker } from '../lib/picker.js';
12
+ import { addHostOption } from '../lib/hosts/option.js';
12
13
  import { inspectRepo, resolveRepoTarget, } from './inspect.js';
13
14
  const HOME = os.homedir();
14
15
  /**
@@ -27,7 +28,7 @@ function resolveRepoPath(target) {
27
28
  return path.join(HOME, `.agents-${trimmed}`);
28
29
  }
29
30
  import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
30
- import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo } from '../lib/git.js';
31
+ import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, displayHomePath, } from '../lib/git.js';
31
32
  import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
32
33
  import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
33
34
  import { refresh } from '../lib/refresh.js';
@@ -465,34 +466,44 @@ async function listRepos(alias, opts = {}) {
465
466
  }
466
467
  console.log('');
467
468
  }
468
- /** Register the `agents repo` command tree. */
469
+ /**
470
+ * Label for push/pull spinners and results: alias + resolved dir + tracking ref.
471
+ * e.g. `user (~/.agents → origin/main)`.
472
+ */
473
+ function formatRepoTarget(alias, dir, branch) {
474
+ const ref = branch ? `origin/${branch}` : 'origin';
475
+ return `${alias} (${displayHomePath(dir)} → ${ref})`;
476
+ }
477
+ /** Register the `agents repos` command tree (`repo` is a convenience alias). */
469
478
  export function registerRepoCommands(program) {
470
- const repoCmd = program
471
- .command('repo')
472
- .alias('repos')
473
- .description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).');
479
+ // addHostOption on the group so --help documents --host/--device; remote
480
+ // routing is handled pre-parse by maybeRunOnHost (passthrough.ts).
481
+ const repoCmd = addHostOption(program
482
+ .command('repos')
483
+ .alias('repo')
484
+ .description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).'));
474
485
  setHelpSections(repoCmd, {
475
486
  examples: `
476
487
  # Scaffold an editable repo (default: ~/.agents/)
477
- agents repo init
488
+ agents repos init
478
489
 
479
490
  # Scaffold a named repo at ~/.agents-work/
480
- agents repo init work
491
+ agents repos init work
481
492
 
482
493
  # Register an existing repo (clones to ~/.agents-<alias>/)
483
- agents repo add gh:yourname/.agents-work
494
+ agents repos add gh:yourname/.agents-work
484
495
 
485
496
  # Register with a custom alias
486
- agents repo add git@github.com:acme/team-skills.git --as acme
497
+ agents repos add git@github.com:acme/team-skills.git --as acme
487
498
 
488
499
  # See what's registered
489
- agents repo list
500
+ agents repos list
490
501
 
491
502
  # View one repo's contents (git state + resource counts); omit the name for a picker
492
503
  agents repos view system
493
504
 
494
505
  # Temporarily disable without deleting
495
- agents repo disable acme
506
+ agents repos disable acme
496
507
  `,
497
508
  notes: `
498
509
  Managed extras live at ~/.agents-<alias>/ as peer dirs to ~/.agents/. User-owned
@@ -850,20 +861,20 @@ export function registerRepoCommands(program) {
850
861
  }
851
862
  continue;
852
863
  }
853
- const spinner = ora(`Pulling ${t.alias}...`).start();
864
+ const spinner = ora(`Pulling ${formatRepoTarget(t.alias, t.dir)}...`).start();
854
865
  const result = await pullRepo(t.dir);
855
866
  if (result.success) {
856
- spinner.succeed(`${t.alias} -> ${result.commit}`);
867
+ spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.commit}`);
857
868
  }
858
869
  else {
859
- spinner.fail(`${t.alias}: ${result.error}`);
870
+ spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
860
871
  }
861
872
  }
862
873
  });
863
874
  repoCmd
864
875
  .command('push [alias]')
865
876
  .description('Commit and push the user repo or a user-owned extra. Refuses to push the system repo.')
866
- .option('-m, --message <msg>', 'Commit message', 'Update via agents repo push')
877
+ .option('-m, --message <msg>', 'Commit message', 'Update via agents repos push')
867
878
  .action(async (alias, options) => {
868
879
  const targets = collectRepoTargets(alias);
869
880
  if (!targets) {
@@ -893,13 +904,13 @@ export function registerRepoCommands(program) {
893
904
  return;
894
905
  }
895
906
  for (const t of pushable) {
896
- const spinner = ora(`Pushing ${t.alias}...`).start();
907
+ const spinner = ora(`Pushing ${formatRepoTarget(t.alias, t.dir)}...`).start();
897
908
  const result = await commitAndPush(t.dir, options.message);
898
909
  if (result.success) {
899
- spinner.succeed(`${t.alias} pushed`);
910
+ spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.detail ?? 'pushed'}`);
900
911
  }
901
912
  else {
902
- spinner.fail(`${t.alias}: ${result.error}`);
913
+ spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
903
914
  }
904
915
  }
905
916
  });
@@ -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 { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
15
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, } from '../lib/routines.js';
15
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, } from '../lib/routines.js';
16
16
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
17
17
  import { getRoutinesDir } from '../lib/state.js';
18
18
  import { IS_WINDOWS } from '../lib/platform/index.js';
@@ -54,13 +54,49 @@ function fireConditionLabel(job) {
54
54
  if (job.schedule)
55
55
  return humanizeCron(job.schedule, job.timezone);
56
56
  if (job.trigger) {
57
- const scope = job.trigger.repo
58
- ? ` (${job.trigger.repo}${job.trigger.branch ? `@${job.trigger.branch}` : ''})`
59
- : '';
60
- return `on ${job.trigger.event}${scope}`;
57
+ if (job.trigger.type === 'github_event') {
58
+ const scope = job.trigger.repo
59
+ ? ` (${job.trigger.repo}${job.trigger.branch ? `@${job.trigger.branch}` : ''})`
60
+ : '';
61
+ return `on github:${job.trigger.event}${scope}`;
62
+ }
63
+ const filters = [
64
+ job.trigger.action ? `action=${job.trigger.action}` : null,
65
+ job.trigger.teamKey ? `team=${job.trigger.teamKey}` : null,
66
+ job.trigger.label ? `label=${job.trigger.label}` : null,
67
+ ].filter(Boolean).join(', ');
68
+ return `on linear:${job.trigger.event}${filters ? ` (${filters})` : ''}`;
61
69
  }
62
70
  return '-';
63
71
  }
72
+ function parseRoutineTrigger(options) {
73
+ const raw = typeof options.on === 'string' ? options.on : undefined;
74
+ if (!raw)
75
+ return undefined;
76
+ const [sourceMaybe, eventMaybe] = raw.includes(':') ? raw.split(':', 2) : ['github', raw];
77
+ if (sourceMaybe === 'github') {
78
+ const event = normalizeTriggerEvent(eventMaybe);
79
+ if (!event)
80
+ throw new Error(`Unknown GitHub trigger event '${eventMaybe}'`);
81
+ const trigger = { type: 'github_event', event };
82
+ if (typeof options.repo === 'string')
83
+ trigger.repo = options.repo;
84
+ if (typeof options.branch === 'string')
85
+ trigger.branch = options.branch;
86
+ return trigger;
87
+ }
88
+ if (sourceMaybe === 'linear') {
89
+ const trigger = { type: 'linear_event', event: eventMaybe };
90
+ if (typeof options.action === 'string')
91
+ trigger.action = options.action;
92
+ if (typeof options.teamKey === 'string')
93
+ trigger.teamKey = options.teamKey;
94
+ if (typeof options.label === 'string')
95
+ trigger.label = options.label;
96
+ return trigger;
97
+ }
98
+ throw new Error('--on source must be github or linear');
99
+ }
64
100
  /** Start or reload the background scheduler so newly-added jobs fire on time. */
65
101
  function ensureSchedulerRunning() {
66
102
  if (isDaemonRunning()) {
@@ -113,7 +149,7 @@ async function pickJob(message, filter, alternatives = [], cwd) {
113
149
  message,
114
150
  choices: jobs.map((job) => ({
115
151
  value: job.name,
116
- name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
152
+ name: `${job.name} ${chalk.gray(`(${job.command ? 'command' : job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
117
153
  })),
118
154
  });
119
155
  }
@@ -246,6 +282,7 @@ export function registerRoutinesCommands(program) {
246
282
  name: job.name,
247
283
  agent: job.agent ?? null,
248
284
  workflow: job.workflow ?? null,
285
+ command: job.command ?? null,
249
286
  repo: job.repo ?? null,
250
287
  schedule: job.schedule ?? null,
251
288
  scheduleHuman: fireConditionLabel(job),
@@ -320,9 +357,11 @@ export function registerRoutinesCommands(program) {
320
357
  : lastStatus === 'timeout' ? chalk.yellow
321
358
  : chalk.gray;
322
359
  const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
323
- const agentLabelPadded = job.workflow
324
- ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
325
- : (job.agent || '').padEnd(10);
360
+ const agentLabelPadded = job.command
361
+ ? chalk.magenta('command'.padEnd(10))
362
+ : job.workflow
363
+ ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
364
+ : (job.agent || '').padEnd(10);
326
365
  console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
327
366
  }
328
367
  if (overdueSet.size > 0) {
@@ -338,6 +377,7 @@ export function registerRoutinesCommands(program) {
338
377
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
339
378
  .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
340
379
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
380
+ .option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
341
381
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
342
382
  .option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier, the default), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'auto')
343
383
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
@@ -345,12 +385,18 @@ export function registerRoutinesCommands(program) {
345
385
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
346
386
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
347
387
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
388
+ .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
389
+ .option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
390
+ .option('--branch <name>', 'GitHub branch filter for --on github:<event>')
391
+ .option('--action <name>', 'Linear action filter for --on linear:<event> (e.g. update)')
392
+ .option('--team-key <key>', 'Linear team key filter for --on linear:<event> (e.g. RUSH)')
393
+ .option('--label <name>', 'Linear issue label filter for --on linear:Issue')
348
394
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
349
395
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
350
396
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
351
397
  .action(async (nameOrPath, options) => {
352
398
  // Check if inline mode (has flags) or file mode
353
- const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at;
399
+ const hasInlineFlags = options.schedule || options.agent || options.workflow || options.command || options.prompt || options.at || options.on;
354
400
  if (hasInlineFlags) {
355
401
  // Inline mode: create job from flags
356
402
  if (!nameOrPath) {
@@ -358,13 +404,21 @@ export function registerRoutinesCommands(program) {
358
404
  console.log(chalk.gray('Usage: agents routines add <name> --schedule "..." --agent <agent> --prompt "..."'));
359
405
  process.exit(1);
360
406
  }
361
- // Validate mutually exclusive --agent / --workflow
362
- if (options.agent && options.workflow) {
363
- console.log(chalk.red('--agent and --workflow are mutually exclusive; specify exactly one'));
407
+ // Validate mutually exclusive --agent / --workflow / --command
408
+ if ([options.agent, options.workflow, options.command].filter(Boolean).length > 1) {
409
+ console.log(chalk.red('--agent, --workflow, and --command are mutually exclusive; specify exactly one'));
364
410
  process.exit(1);
365
411
  }
366
412
  let schedule = options.schedule;
413
+ let trigger;
367
414
  let runOnce = false;
415
+ try {
416
+ trigger = parseRoutineTrigger(options);
417
+ }
418
+ catch (err) {
419
+ console.log(chalk.red(err.message));
420
+ process.exit(1);
421
+ }
368
422
  // Handle --at for one-shot jobs
369
423
  if (options.at) {
370
424
  const parsed = parseAtTime(options.at);
@@ -376,15 +430,16 @@ export function registerRoutinesCommands(program) {
376
430
  schedule = parsed.schedule;
377
431
  runOnce = parsed.runOnce;
378
432
  }
379
- if (!schedule) {
380
- console.log(chalk.red('Schedule is required (use --schedule or --at)'));
433
+ if (!schedule && !trigger) {
434
+ console.log(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
381
435
  process.exit(1);
382
436
  }
383
- if (!options.agent && !options.workflow) {
384
- console.log(chalk.red('An agent or workflow is required (use --agent or --workflow)'));
437
+ if (!options.agent && !options.workflow && !options.command) {
438
+ console.log(chalk.red('An agent, workflow, or command is required (use --agent, --workflow, or --command)'));
385
439
  process.exit(1);
386
440
  }
387
- if (!options.prompt) {
441
+ // Command routines run a plain shell and take no prompt; agent/workflow routines require one.
442
+ if (!options.command && !options.prompt) {
388
443
  console.log(chalk.red('Prompt is required (use --prompt)'));
389
444
  process.exit(1);
390
445
  }
@@ -395,14 +450,16 @@ export function registerRoutinesCommands(program) {
395
450
  }
396
451
  const config = {
397
452
  name: nameOrPath,
398
- schedule,
399
- agent: options.agent,
453
+ ...(schedule ? { schedule } : {}),
454
+ ...(trigger ? { trigger } : {}),
455
+ ...(options.agent ? { agent: options.agent } : {}),
400
456
  ...(options.workflow ? { workflow: options.workflow } : {}),
457
+ ...(options.command ? { command: options.command } : {}),
401
458
  mode: options.mode,
402
459
  effort: options.effort,
403
460
  timeout: options.timeout,
404
461
  enabled: !options.disabled,
405
- prompt: options.prompt,
462
+ prompt: options.prompt ?? '',
406
463
  timezone: options.timezone,
407
464
  ...(devices ? { devices } : {}),
408
465
  ...(runOnce ? { runOnce: true } : {}),
@@ -613,7 +670,7 @@ export function registerRoutinesCommands(program) {
613
670
  console.log(chalk.gray(` ${eligibility.suggestion}`));
614
671
  process.exit(1);
615
672
  }
616
- const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
673
+ const runLabel = job.command ? 'command' : job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
617
674
  console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
618
675
  const spinner = ora('Executing...').start();
619
676
  try {
@@ -686,11 +743,17 @@ export function registerRoutinesCommands(program) {
686
743
  });
687
744
  routinesCmd
688
745
  .command('webhook')
689
- .description('Fire trigger-based routines from a single GitHub webhook payload (read from --file or stdin). One-shot: matches and fires, then exits. For a long-running receiver, run this behind your own HTTP forwarder.')
690
- .requiredOption('--event <name>', 'GitHub event name, as sent in the X-GitHub-Event header (e.g. pull_request, push, workflow_run)')
746
+ .description('Fire trigger-based routines from a single webhook payload (read from --file or stdin). One-shot: matches and fires, then exits.')
747
+ .option('--source <name>', 'Webhook source: github or linear', 'github')
748
+ .requiredOption('--event <name>', 'Source event name: GitHub X-GitHub-Event value, or Linear payload type')
691
749
  .option('--file <path>', 'Read the webhook JSON payload from this file instead of stdin')
692
750
  .option('--dry-run', 'Show which routines would fire without firing them')
693
751
  .action(async (options) => {
752
+ const source = options.source;
753
+ if (source !== 'github' && source !== 'linear') {
754
+ console.log(chalk.red(`Unknown webhook source "${options.source}". Use github or linear.`));
755
+ process.exit(1);
756
+ }
694
757
  // Load the raw JSON payload: --file wins, else drain stdin.
695
758
  let raw;
696
759
  if (options.file) {
@@ -723,7 +786,7 @@ export function registerRoutinesCommands(program) {
723
786
  console.log(chalk.red(`Invalid webhook payload JSON: ${err.message}`));
724
787
  process.exit(1);
725
788
  }
726
- const webhook = { event: options.event, payload };
789
+ const webhook = { source, event: options.event, payload };
727
790
  // Matching is intentionally user-layer only (fireWebhookJobs defaults to
728
791
  // listJobs() with no cwd), mirroring `run`/`catchup`: a webhook must never
729
792
  // fire a cloned project repo's `.agents/routines/*.yml` and run an
@@ -731,10 +794,10 @@ export function registerRoutinesCommands(program) {
731
794
  if (options.dryRun) {
732
795
  const matched = matchJobsToWebhook(listAllJobs(), webhook);
733
796
  if (matched.length === 0) {
734
- console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
797
+ console.log(chalk.gray(`No routines match a ${source}:${options.event} event for this payload.`));
735
798
  return;
736
799
  }
737
- console.log(chalk.bold(`${matched.length} routine(s) would fire on ${options.event}:\n`));
800
+ console.log(chalk.bold(`${matched.length} routine(s) would fire on ${source}:${options.event}:\n`));
738
801
  for (const job of matched) {
739
802
  console.log(` ${chalk.cyan(job.name)} — ${fireConditionLabel(job)}`);
740
803
  }
@@ -751,10 +814,10 @@ export function registerRoutinesCommands(program) {
751
814
  }
752
815
  const fired = await fireWebhookJobs(webhook);
753
816
  if (fired.length === 0) {
754
- console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
817
+ console.log(chalk.gray(`No routines match a ${source}:${options.event} event for this payload.`));
755
818
  return;
756
819
  }
757
- console.log(chalk.bold(`Fired ${fired.length} routine(s) on ${options.event}:\n`));
820
+ console.log(chalk.bold(`Fired ${fired.length} routine(s) on ${source}:${options.event}:\n`));
758
821
  for (const f of fired) {
759
822
  console.log(` ${chalk.cyan(f.jobName)} → ${chalk.green('started')} (run: ${f.runId})`);
760
823
  }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `agents sessions export` — bundle N selected sessions into a portable,
3
+ * self-describing archive (RUSH-1710).
4
+ *
5
+ * The successor to background R2/CRDT sync for the durable-archive / hand-off
6
+ * case: instead of an always-on merge daemon, the user explicitly bundles the
7
+ * sessions they want to carry to an offline box or keep as an archive. The
8
+ * bundle format + placement live in ../lib/session/bundle.ts; this command owns
9
+ * only the SELECTION (which sessions) and the OUTPUT (file or stdout).
10
+ *
11
+ * Selection flags (`--since`, `-n/--limit`, `--all`, `-a/--agent`,
12
+ * `--no-redact`) are inherited from the parent `sessions` command and read via
13
+ * optsWithGlobals(), so they never shadow the parent's parsing; this command
14
+ * adds only the export-specific flags (`-o/--output`, `--stdout`, `--encrypt`).
15
+ *
16
+ * Rendered markdown/json of a single session is already served by
17
+ * `agents sessions <id> --markdown|--json`; export is specifically the portable,
18
+ * re-importable BUNDLE, so it does not re-expose those render formats.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import chalk from 'chalk';
23
+ import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
24
+ import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
25
+ import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
26
+ import { machineId } from '../lib/machine-id.js';
27
+ import { getHistoryDir } from '../lib/state.js';
28
+ import { loadR2Config } from '../lib/session/sync/config.js';
29
+ import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
30
+ import { buildRecord, makeHeader, mergeRecords, serializeBundle, specForAgent, } from '../lib/session/bundle.js';
31
+ import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
32
+ import { setHelpSections } from '../lib/help.js';
33
+ /** Default cap when exporting a scope (not explicit ids) and the user gave no -n. */
34
+ const DEFAULT_LIMIT = 500;
35
+ export function registerSessionsExportCommand(sessionsCmd) {
36
+ const cmd = sessionsCmd
37
+ .command('export [selectors...]')
38
+ .description('Bundle sessions (by id, query, or the parent selection flags like --since/-a) into a portable archive.')
39
+ .option('-o, --output <path>', 'Write the bundle to this file')
40
+ .option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
41
+ .option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing');
42
+ setHelpSections(cmd, {
43
+ examples: `# Bundle the last week of sessions to a file
44
+ agents sessions export --since 7d -o week.bundle
45
+
46
+ # Bundle two specific sessions
47
+ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
48
+
49
+ # Encrypt + pipe straight into another machine over SSH
50
+ agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'`,
51
+ notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
52
+ -a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
53
+ line per transcript file. Secrets are redacted by default. Dir-shaped sessions
54
+ (Kimi) carry all their files. Restore with 'agents sessions import'.`,
55
+ });
56
+ cmd.action(async (selectors, _options, command) => {
57
+ await runExport(selectors, command);
58
+ });
59
+ }
60
+ async function runExport(selectors, command) {
61
+ const g = command.optsWithGlobals();
62
+ // --host: export sessions that live on remote peer(s) — run export there and
63
+ // stream the bundle back over the existing SSH transport (RUSH-1712).
64
+ if (g.host && g.host.length > 0) {
65
+ await runRemoteExport(g, selectors, command);
66
+ return;
67
+ }
68
+ const explicitLimit = command.parent?.getOptionValueSource?.('limit') === 'cli';
69
+ const limit = explicitLimit ? Math.max(1, parseInt(String(g.limit), 10) || DEFAULT_LIMIT) : DEFAULT_LIMIT;
70
+ const agentFilter = parseAgentFilter(resolveAgentShorthand(g));
71
+ // 1. Discover candidate sessions in scope.
72
+ const metas = await discoverSessions({
73
+ all: g.all !== false,
74
+ agent: agentFilter.agent ?? undefined,
75
+ since: g.since,
76
+ limit,
77
+ });
78
+ // 2. Narrow to the selection (ids > query > everything-in-scope).
79
+ const selected = selectSessions(metas, selectors);
80
+ if (selected.length === 0) {
81
+ process.stderr.write(chalk.yellow('No sessions matched the selection.\n'));
82
+ process.exit(1);
83
+ }
84
+ if (!selectors.length && selected.length >= limit) {
85
+ process.stderr.write(chalk.yellow(`Note: capped at ${limit} sessions. Raise -n to bundle more.\n`));
86
+ }
87
+ // 3. Resolve each selected session to its on-disk file(s).
88
+ const index = buildLocalIndex();
89
+ const self = machineId();
90
+ const files = [];
91
+ const skippedAgents = new Set();
92
+ for (const meta of selected) {
93
+ const spec = specForAgent(meta.agent);
94
+ if (!spec) {
95
+ skippedAgents.add(meta.agent);
96
+ continue;
97
+ }
98
+ const machine = meta.machine || self;
99
+ const lt = index.get(`${meta.agent}:${meta.id}`);
100
+ if (lt) {
101
+ for (const f of lt.files) {
102
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey: f.relKey, absPath: f.absPath, label: meta.label });
103
+ }
104
+ }
105
+ else if (meta.filePath && fs.existsSync(meta.filePath)) {
106
+ // Not in the live-home index (e.g. a mirror of another machine): fall back
107
+ // to the single discovered file, deriving its subdir-relative key.
108
+ const relKey = relKeyFromPath(meta.filePath, meta.agent, machine, spec.subdir);
109
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey, absPath: meta.filePath, label: meta.label });
110
+ }
111
+ }
112
+ if (skippedAgents.size > 0) {
113
+ process.stderr.write(chalk.yellow(`Skipped agents with no portable format: ${[...skippedAgents].sort().join(', ')}.\n`));
114
+ }
115
+ if (files.length === 0) {
116
+ process.stderr.write(chalk.red('Selected sessions have no exportable transcript files.\n'));
117
+ process.exit(1);
118
+ }
119
+ // 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
120
+ const encryptKey = g.encrypt ? resolveExportKey() : null;
121
+ const redact = g.redact !== false;
122
+ // 5. Build records + header.
123
+ const records = [];
124
+ for (const f of files) {
125
+ try {
126
+ records.push(buildRecord(f, { redact, encryptKey }));
127
+ }
128
+ catch (err) {
129
+ process.stderr.write(chalk.yellow(`Skipped ${f.agent}/${f.sessionId} (${f.relKey}): ${err.message}\n`));
130
+ }
131
+ }
132
+ if (records.length === 0) {
133
+ process.stderr.write(chalk.red('Nothing to export after reading files.\n'));
134
+ process.exit(1);
135
+ }
136
+ const header = makeHeader({
137
+ origin: self,
138
+ exportedAt: new Date().toISOString(),
139
+ encrypted: encryptKey !== null,
140
+ redacted: redact,
141
+ records,
142
+ });
143
+ emitBundle(header, records, g);
144
+ }
145
+ /**
146
+ * --host path: run `agents sessions export …` on each peer over SSH, stream the
147
+ * bundles back, merge (dedup by origin machine) and emit one local bundle.
148
+ * Encryption is not combined with a remote pull (each peer would seal under its
149
+ * own key); the SSH transport already encrypts the stream in transit.
150
+ */
151
+ async function runRemoteExport(g, selectors, command) {
152
+ if (g.encrypt) {
153
+ process.stderr.write(chalk.yellow('Note: --encrypt is ignored with --host (the SSH stream is already encrypted). Encrypt a local bundle instead.\n'));
154
+ }
155
+ const { bundles, errors } = await pullBundlesFromHosts(g.host, forwardExportArgs(g, selectors, command));
156
+ for (const e of errors)
157
+ process.stderr.write(chalk.yellow(` ${e}\n`));
158
+ const records = mergeRecords(bundles.map(b => b.records));
159
+ if (records.length === 0) {
160
+ process.stderr.write(chalk.red('No sessions pulled from the given host(s).\n'));
161
+ process.exit(1);
162
+ }
163
+ const header = makeHeader({
164
+ origin: g.host.join(','),
165
+ exportedAt: new Date().toISOString(),
166
+ encrypted: false,
167
+ redacted: g.redact !== false,
168
+ records,
169
+ });
170
+ emitBundle(header, records, g);
171
+ }
172
+ /** Reconstruct the export flags to forward to a peer's own `sessions export`. */
173
+ function forwardExportArgs(g, selectors, command) {
174
+ const args = [...selectors];
175
+ if (g.since)
176
+ args.push('--since', g.since);
177
+ const agent = resolveAgentShorthand(g);
178
+ if (agent)
179
+ args.push('-a', agent);
180
+ if (g.all !== false)
181
+ args.push('--all');
182
+ if (g.redact === false)
183
+ args.push('--no-redact');
184
+ if (command.parent?.getOptionValueSource?.('limit') === 'cli' && g.limit)
185
+ args.push('-n', String(g.limit));
186
+ return args;
187
+ }
188
+ /** Write the assembled bundle to stdout or a file. */
189
+ function emitBundle(header, records, g) {
190
+ const wire = serializeBundle(header, records);
191
+ if (g.stdout) {
192
+ process.stdout.write(wire);
193
+ return;
194
+ }
195
+ const outPath = g.output || defaultBundlePath();
196
+ fs.writeFileSync(outPath, wire, 'utf-8');
197
+ process.stderr.write(chalk.green(`Exported ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
198
+ `(${header.count} file${header.count === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ''}${header.redacted ? ', redacted' : ''}) ` +
199
+ `→ ${outPath}\n`));
200
+ }
201
+ /** Map the parent's agent shorthands (--claude, --codex, …) or -a/--agent to a filter string. */
202
+ function resolveAgentShorthand(g) {
203
+ if (g.agent)
204
+ return g.agent;
205
+ if (g.claude)
206
+ return 'claude';
207
+ if (g.codex)
208
+ return 'codex';
209
+ if (g.kimi)
210
+ return 'kimi';
211
+ if (g.grok)
212
+ return 'grok';
213
+ if (g.opencode)
214
+ return 'opencode';
215
+ if (g.antigravity)
216
+ return 'antigravity';
217
+ return undefined;
218
+ }
219
+ /** ids > query > everything-in-scope. */
220
+ function selectSessions(metas, selectors) {
221
+ if (selectors.length === 0)
222
+ return metas;
223
+ const byId = [];
224
+ const unmatched = [];
225
+ for (const sel of selectors) {
226
+ const hits = resolveSessionById(metas, sel);
227
+ if (hits.length > 0)
228
+ byId.push(...hits);
229
+ else
230
+ unmatched.push(sel);
231
+ }
232
+ if (byId.length > 0 && unmatched.length === 0) {
233
+ const seen = new Set();
234
+ return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
235
+ }
236
+ // Any selector that isn't an id → treat the whole thing as a text query.
237
+ return filterSessionsByQuery(metas, selectors.join(' '));
238
+ }
239
+ /** Build `${agent}:${sessionId}` → LocalTranscript across every sync agent (live home only). */
240
+ function buildLocalIndex() {
241
+ const index = new Map();
242
+ for (const spec of SYNC_AGENTS) {
243
+ for (const lt of listLocalTranscripts(spec)) {
244
+ index.set(`${spec.id}:${lt.sessionId}`, lt);
245
+ }
246
+ }
247
+ return index;
248
+ }
249
+ /** Derive the subdir-relative key for a mirror file path, else fall back to the basename. */
250
+ function relKeyFromPath(filePath, agent, machine, subdir) {
251
+ const prefix = path.join(getHistoryDir(), 'backups', agent, machine, subdir) + path.sep;
252
+ if (filePath.startsWith(prefix))
253
+ return filePath.slice(prefix.length);
254
+ return path.basename(filePath);
255
+ }
256
+ /**
257
+ * Resolve the AES key for --encrypt: prefer the fleet-shared R2_SYNC_ENC_KEY (so
258
+ * any machine on the sync bundle can decrypt), else mint an ephemeral key and
259
+ * print it once — it is NOT stored in the bundle.
260
+ */
261
+ function resolveExportKey() {
262
+ try {
263
+ const key = resolveSyncEncKey(loadR2Config());
264
+ if (key)
265
+ return key;
266
+ }
267
+ catch {
268
+ // sync bundle not configured — fall through to an ephemeral key
269
+ }
270
+ const b64 = generateSyncEncKey();
271
+ process.stderr.write(chalk.yellow(`Bundle encrypted with a fresh key (not in the bundle). Decrypt with:\n` +
272
+ ` agents sessions import <bundle> --decrypt ${b64}\n`));
273
+ return Buffer.from(b64, 'base64');
274
+ }
275
+ /** Default output file when neither -o nor --stdout is given. */
276
+ function defaultBundlePath() {
277
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
278
+ return path.join(process.cwd(), `agents-sessions-${stamp}.bundle`);
279
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsImportCommand(sessionsCmd: Command): void;