@phnx-labs/agents-cli 1.20.62 → 1.20.63

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 (65) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +41 -0
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +58 -18
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/daemon.js +2 -0
  32. package/dist/lib/devices/fleet.d.ts +62 -0
  33. package/dist/lib/devices/fleet.js +128 -0
  34. package/dist/lib/funnel.d.ts +5 -0
  35. package/dist/lib/funnel.js +23 -0
  36. package/dist/lib/git.d.ts +21 -5
  37. package/dist/lib/git.js +64 -14
  38. package/dist/lib/hosts/credentials.d.ts +28 -0
  39. package/dist/lib/hosts/credentials.js +48 -0
  40. package/dist/lib/hosts/dispatch.d.ts +25 -0
  41. package/dist/lib/hosts/dispatch.js +68 -2
  42. package/dist/lib/hosts/passthrough.d.ts +13 -10
  43. package/dist/lib/hosts/passthrough.js +119 -29
  44. package/dist/lib/mailbox-gc.js +4 -16
  45. package/dist/lib/migrate.d.ts +12 -0
  46. package/dist/lib/migrate.js +55 -1
  47. package/dist/lib/routines.d.ts +29 -10
  48. package/dist/lib/routines.js +47 -15
  49. package/dist/lib/session/sync/agents.d.ts +2 -0
  50. package/dist/lib/session/sync/agents.js +39 -1
  51. package/dist/lib/session/sync/config.d.ts +8 -0
  52. package/dist/lib/session/sync/config.js +6 -1
  53. package/dist/lib/session/sync/provision.d.ts +49 -0
  54. package/dist/lib/session/sync/provision.js +91 -0
  55. package/dist/lib/session/sync/sync.d.ts +3 -0
  56. package/dist/lib/session/sync/sync.js +26 -6
  57. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  58. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  59. package/dist/lib/startup/command-registry.d.ts +2 -0
  60. package/dist/lib/startup/command-registry.js +11 -0
  61. package/dist/lib/state.d.ts +10 -2
  62. package/dist/lib/state.js +14 -2
  63. package/dist/lib/triggers/webhook.d.ts +70 -27
  64. package/dist/lib/triggers/webhook.js +264 -43
  65. 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 { 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()) {
@@ -345,12 +381,18 @@ export function registerRoutinesCommands(program) {
345
381
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
346
382
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
347
383
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
384
+ .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
385
+ .option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
386
+ .option('--branch <name>', 'GitHub branch filter for --on github:<event>')
387
+ .option('--action <name>', 'Linear action filter for --on linear:<event> (e.g. update)')
388
+ .option('--team-key <key>', 'Linear team key filter for --on linear:<event> (e.g. RUSH)')
389
+ .option('--label <name>', 'Linear issue label filter for --on linear:Issue')
348
390
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
349
391
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
350
392
  .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
393
  .action(async (nameOrPath, options) => {
352
394
  // Check if inline mode (has flags) or file mode
353
- const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at;
395
+ const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at || options.on;
354
396
  if (hasInlineFlags) {
355
397
  // Inline mode: create job from flags
356
398
  if (!nameOrPath) {
@@ -364,7 +406,15 @@ export function registerRoutinesCommands(program) {
364
406
  process.exit(1);
365
407
  }
366
408
  let schedule = options.schedule;
409
+ let trigger;
367
410
  let runOnce = false;
411
+ try {
412
+ trigger = parseRoutineTrigger(options);
413
+ }
414
+ catch (err) {
415
+ console.log(chalk.red(err.message));
416
+ process.exit(1);
417
+ }
368
418
  // Handle --at for one-shot jobs
369
419
  if (options.at) {
370
420
  const parsed = parseAtTime(options.at);
@@ -376,8 +426,8 @@ export function registerRoutinesCommands(program) {
376
426
  schedule = parsed.schedule;
377
427
  runOnce = parsed.runOnce;
378
428
  }
379
- if (!schedule) {
380
- console.log(chalk.red('Schedule is required (use --schedule or --at)'));
429
+ if (!schedule && !trigger) {
430
+ console.log(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
381
431
  process.exit(1);
382
432
  }
383
433
  if (!options.agent && !options.workflow) {
@@ -395,7 +445,8 @@ export function registerRoutinesCommands(program) {
395
445
  }
396
446
  const config = {
397
447
  name: nameOrPath,
398
- schedule,
448
+ ...(schedule ? { schedule } : {}),
449
+ ...(trigger ? { trigger } : {}),
399
450
  agent: options.agent,
400
451
  ...(options.workflow ? { workflow: options.workflow } : {}),
401
452
  mode: options.mode,
@@ -686,11 +737,17 @@ export function registerRoutinesCommands(program) {
686
737
  });
687
738
  routinesCmd
688
739
  .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)')
740
+ .description('Fire trigger-based routines from a single webhook payload (read from --file or stdin). One-shot: matches and fires, then exits.')
741
+ .option('--source <name>', 'Webhook source: github or linear', 'github')
742
+ .requiredOption('--event <name>', 'Source event name: GitHub X-GitHub-Event value, or Linear payload type')
691
743
  .option('--file <path>', 'Read the webhook JSON payload from this file instead of stdin')
692
744
  .option('--dry-run', 'Show which routines would fire without firing them')
693
745
  .action(async (options) => {
746
+ const source = options.source;
747
+ if (source !== 'github' && source !== 'linear') {
748
+ console.log(chalk.red(`Unknown webhook source "${options.source}". Use github or linear.`));
749
+ process.exit(1);
750
+ }
694
751
  // Load the raw JSON payload: --file wins, else drain stdin.
695
752
  let raw;
696
753
  if (options.file) {
@@ -723,7 +780,7 @@ export function registerRoutinesCommands(program) {
723
780
  console.log(chalk.red(`Invalid webhook payload JSON: ${err.message}`));
724
781
  process.exit(1);
725
782
  }
726
- const webhook = { event: options.event, payload };
783
+ const webhook = { source, event: options.event, payload };
727
784
  // Matching is intentionally user-layer only (fireWebhookJobs defaults to
728
785
  // listJobs() with no cwd), mirroring `run`/`catchup`: a webhook must never
729
786
  // fire a cloned project repo's `.agents/routines/*.yml` and run an
@@ -731,10 +788,10 @@ export function registerRoutinesCommands(program) {
731
788
  if (options.dryRun) {
732
789
  const matched = matchJobsToWebhook(listAllJobs(), webhook);
733
790
  if (matched.length === 0) {
734
- console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
791
+ console.log(chalk.gray(`No routines match a ${source}:${options.event} event for this payload.`));
735
792
  return;
736
793
  }
737
- console.log(chalk.bold(`${matched.length} routine(s) would fire on ${options.event}:\n`));
794
+ console.log(chalk.bold(`${matched.length} routine(s) would fire on ${source}:${options.event}:\n`));
738
795
  for (const job of matched) {
739
796
  console.log(` ${chalk.cyan(job.name)} — ${fireConditionLabel(job)}`);
740
797
  }
@@ -751,10 +808,10 @@ export function registerRoutinesCommands(program) {
751
808
  }
752
809
  const fired = await fireWebhookJobs(webhook);
753
810
  if (fired.length === 0) {
754
- console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
811
+ console.log(chalk.gray(`No routines match a ${source}:${options.event} event for this payload.`));
755
812
  return;
756
813
  }
757
- console.log(chalk.bold(`Fired ${fired.length} routine(s) on ${options.event}:\n`));
814
+ console.log(chalk.bold(`Fired ${fired.length} routine(s) on ${source}:${options.event}:\n`));
758
815
  for (const f of fired) {
759
816
  console.log(` ${chalk.cyan(f.jobName)} → ${chalk.green('started')} (run: ${f.runId})`);
760
817
  }
@@ -10,6 +10,7 @@ interface SyncCmdOptions {
10
10
  enable?: boolean;
11
11
  disable?: boolean;
12
12
  status?: boolean;
13
+ setup?: boolean;
13
14
  }
14
15
  export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
15
16
  export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
@@ -26,6 +26,11 @@ export async function runSessionsSync(options) {
26
26
  chalk.dim(' — the daemon resumes on its next cycle. (Same as: agents beta enable session-sync)'));
27
27
  return;
28
28
  }
29
+ if (options.setup) {
30
+ const { promptAndProvisionSessionSync } = await import('./sync-provision.js');
31
+ await promptAndProvisionSessionSync({ explicit: true });
32
+ return;
33
+ }
29
34
  if (options.status) {
30
35
  const enabled = isBetaEnabled(SYNC_BETA);
31
36
  const configured = isSyncConfigured();
@@ -47,7 +52,8 @@ export async function runSessionsSync(options) {
47
52
  ` agents secrets add ${SYNC_BUNDLE} R2_ACCOUNT_ID\n` +
48
53
  ` agents secrets add ${SYNC_BUNDLE} R2_BUCKET_NAME\n` +
49
54
  ` agents secrets add ${SYNC_BUNDLE} R2_ACCESS_KEY_ID\n` +
50
- ` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY`);
55
+ ` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY\n` +
56
+ chalk.dim('\nOr let a guided prompt mint the bundle for you: ') + chalk.cyan('agents sessions sync --setup'));
51
57
  process.exitCode = 1;
52
58
  return;
53
59
  }
@@ -68,6 +74,10 @@ export async function runSessionsSync(options) {
68
74
  console.log(chalk.green('synced') + ` ${result.machine}: ` + parts.join(', ') +
69
75
  chalk.dim(` (${result.pushSkipped + result.pullSkipped} unchanged)`));
70
76
  }
77
+ if (!options.json && result.warnings.length > 0) {
78
+ for (const w of result.warnings)
79
+ console.error(chalk.yellow(` warning: ${w}`));
80
+ }
71
81
  if (result.errors.length > 0) {
72
82
  for (const e of result.errors)
73
83
  console.error(chalk.yellow(` ! ${e}`));
@@ -82,14 +92,18 @@ export async function runSessionsSync(options) {
82
92
  export function registerSessionsSyncCommand(sessionsCmd) {
83
93
  const syncCmd = sessionsCmd
84
94
  .command('sync')
85
- .description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
95
+ .description('Sync session transcripts across machines via R2 (CRDT merge). Claude, Codex, Droid, Grok, Kimi, and OpenCode.')
86
96
  .option('-v, --verbose', 'Log each pushed and pulled session')
87
97
  .option('--json', 'Output the sync result as JSON')
98
+ .option('--setup', 'Guided provisioning: mint the r2.backups bundle, verify connectivity, and enable sync')
88
99
  .option('--enable', 'Opt in to automatic background sync (beta; alias for: agents beta enable session-sync)')
89
100
  .option('--disable', 'Opt out of automatic background sync (alias for: agents beta disable session-sync)')
90
101
  .option('--status', 'Show whether automatic sync is opted-in (beta) and configured');
91
102
  setHelpSections(syncCmd, {
92
103
  examples: `
104
+ # Guided first-time setup (mint R2 bundle, verify, enable)
105
+ agents sessions sync --setup
106
+
93
107
  # One sync cycle (push local changes, pull + merge from other machines)
94
108
  agents sessions sync
95
109
 
@@ -44,6 +44,7 @@ import { registerSessionsResumeCommand } from './sessions-resume.js';
44
44
  import { registerGoCommand } from './go.js';
45
45
  import { registerFocusCommand } from './focus.js';
46
46
  import { registerSessionsInjectCommand } from './sessions-inject.js';
47
+ import { runBrowserSessions } from '../lib/browser/sessions-list.js';
47
48
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
48
49
  /**
49
50
  * The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
@@ -2065,7 +2066,8 @@ export function registerSessionsCommands(program) {
2065
2066
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
2066
2067
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
2067
2068
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
2068
- .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
2069
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
2070
+ .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`');
2069
2071
  setHelpSections(sessionsCmd, {
2070
2072
  examples: `
2071
2073
  # Search prior sessions in this project by topic, file path, or command
@@ -2107,6 +2109,11 @@ export function registerSessionsCommands(program) {
2107
2109
  `,
2108
2110
  });
2109
2111
  sessionsCmd.action(async (query, options) => {
2112
+ if (options.browser) {
2113
+ // Alias for `agents browser sessions`: a profile positional narrows to one profile.
2114
+ runBrowserSessions({ profile: query, json: options.json });
2115
+ return;
2116
+ }
2110
2117
  await sessionsAction(query, options);
2111
2118
  });
2112
2119
  registerSessionsTailCommand(sessionsCmd);
@@ -119,6 +119,15 @@ export async function runSetup(program, options = {}) {
119
119
  if (dev.ok && dev.synced > 0) {
120
120
  console.log(chalk.gray(`Discovered ${dev.synced} device${dev.synced === 1 ? '' : 's'} on your tailnet (agents devices list).`));
121
121
  }
122
+ // Offer guided cross-machine session-sync provisioning (interactive, opt-in,
123
+ // and never blocking — any failure/decline falls through to the rest of setup).
124
+ try {
125
+ const { promptAndProvisionSessionSync } = await import('./sync-provision.js');
126
+ await promptAndProvisionSessionSync({ explicit: false });
127
+ }
128
+ catch (err) {
129
+ console.log(chalk.yellow(`Session-sync setup skipped: ${err.message}`));
130
+ }
122
131
  // Offer to import existing unmanaged installations
123
132
  if (unmanaged.length > 0 && isInteractiveTerminal()) {
124
133
  console.log(chalk.bold('\nFound existing installations:\n'));
@@ -25,6 +25,7 @@ import { clearPendingSentinel } from '../lib/devices/pending.js';
25
25
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
26
26
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
27
27
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
28
+ import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
28
29
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
29
30
  * command is running on so it stands out from the rest of the tailnet. */
30
31
  function deviceSummary(d, isSelf = false) {
@@ -118,11 +119,34 @@ async function runInteractiveDeviceSync() {
118
119
  ].filter(Boolean);
119
120
  console.log(parts.join(chalk.gray(' · ')));
120
121
  }
121
- /** Register the `agents devices` command tree. */
122
+ /** Print a per-device result table for fleet update/run. */
123
+ function printFleetResults(results) {
124
+ const nameW = Math.max(8, ...results.map((r) => r.name.length));
125
+ console.log(chalk.bold('DEVICE'.padEnd(nameW)) + ' ' +
126
+ chalk.bold('STATUS'.padEnd(8)) + ' ' +
127
+ chalk.bold('DETAIL'));
128
+ for (const r of results) {
129
+ const status = r.status === 'ok' ? chalk.green('ok'.padEnd(8)) :
130
+ r.status === 'skipped' ? chalk.gray('skipped'.padEnd(8)) :
131
+ chalk.red('failed'.padEnd(8));
132
+ const detail = r.status === 'skipped' ? chalk.gray(skipLabel(r.reason)) :
133
+ r.status === 'failed' ? chalk.red(r.detail || `exit ${r.code ?? '?'}`) :
134
+ chalk.gray(r.code === 0 ? 'exit 0' : '');
135
+ console.log(`${r.name.padEnd(nameW)} ${status} ${detail}`);
136
+ }
137
+ const ok = results.filter((r) => r.status === 'ok').length;
138
+ const failed = results.filter((r) => r.status === 'failed').length;
139
+ const skipped = results.filter((r) => r.status === 'skipped').length;
140
+ console.log(chalk.gray(`${ok} ok · ${failed} failed · ${skipped} skipped`));
141
+ if (failed > 0)
142
+ process.exitCode = 1;
143
+ }
144
+ /** Register the `agents devices` command tree (also aliased as `fleet`). */
122
145
  function registerDevicesCommands(program) {
123
146
  const devicesCmd = program
124
147
  .command('devices')
125
- .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
148
+ .alias('fleet')
149
+ .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale. Alias: fleet.')
126
150
  .addHelpText('after', `
127
151
  Typical workflow:
128
152
  agents devices sync # curate: pick which tailscale nodes to keep (TTY)
@@ -131,6 +155,10 @@ Typical workflow:
131
155
  agents devices ignore ipad165 # dismiss a node so it's never re-suggested
132
156
  agents devices set win-mini --auth password --bundle muqsit
133
157
  agents devices render --write # write ~/.ssh/config.d/agents include
158
+ agents fleet update # roll out latest agents-cli to every online device
159
+ agents fleet run uname -a # run a command on every online device
160
+
161
+ \`agents fleet\` is an alias for \`agents devices\` — same subcommands.
134
162
  `);
135
163
  devicesCmd
136
164
  .command('sync')
@@ -307,6 +335,48 @@ Typical workflow:
307
335
  console.log(chalk.green(`Wrote ${file}`));
308
336
  console.log(chalk.gray('Add this to ~/.ssh/config (once): Include config.d/agents'));
309
337
  });
338
+ devicesCmd
339
+ .command('update')
340
+ .description('Roll out agents-cli to every online registered device (`agents upgrade --yes` on each). Offline devices are skipped.')
341
+ .argument('[version]', 'Target version or dist-tag (default: latest)')
342
+ .action(async (version) => {
343
+ let cmd;
344
+ try {
345
+ cmd = upgradeCommand(version);
346
+ }
347
+ catch (err) {
348
+ console.error(chalk.red(err?.message ?? err));
349
+ process.exit(1);
350
+ }
351
+ const reg = await loadDevices();
352
+ const targets = planFleetTargets(reg);
353
+ if (targets.length === 0) {
354
+ console.log(chalk.gray("No devices. Run 'agents devices sync' first."));
355
+ return;
356
+ }
357
+ console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
358
+ const results = runFleet(targets, cmd);
359
+ printFleetResults(results);
360
+ });
361
+ devicesCmd
362
+ .command('run <cmd...>')
363
+ .description('Run a command on every online registered device. Offline devices are skipped. Alias surface: agents fleet run …')
364
+ .allowUnknownOption()
365
+ .action(async (cmd) => {
366
+ if (!cmd.length) {
367
+ console.error(chalk.red('Usage: agents fleet run <cmd...>'));
368
+ process.exit(1);
369
+ }
370
+ const reg = await loadDevices();
371
+ const targets = planFleetTargets(reg);
372
+ if (targets.length === 0) {
373
+ console.log(chalk.gray("No devices. Run 'agents devices sync' first."));
374
+ return;
375
+ }
376
+ console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
377
+ const results = runFleet(targets, cmd);
378
+ printFleetResults(results);
379
+ });
310
380
  }
311
381
  /** Register the `agents ssh` smart wrapper. */
312
382
  function registerSshWrapper(program) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Interactive provisioning flow for cross-machine session sync. Collects R2
3
+ * credentials, writes the `r2.backups` bundle (via the pure lib helper), probes
4
+ * connectivity, and opts the machine into the `session-sync` beta. Shared by
5
+ * `agents setup` (offered, opt-in) and `agents sessions sync --setup` (explicit).
6
+ *
7
+ * The prompt/UI lives here in the command layer; the credential-writing and
8
+ * connectivity logic is the pure, unit-tested `lib/session/sync/provision.ts`.
9
+ */
10
+ export interface ProvisionFlowOptions {
11
+ /**
12
+ * True when invoked explicitly (`agents sessions sync --setup`): we go straight
13
+ * into provisioning. False when offered inside `agents setup`: we ask first,
14
+ * default to No, and silently skip when already configured.
15
+ */
16
+ explicit?: boolean;
17
+ }
18
+ /**
19
+ * Run the guided session-sync setup. Never throws for expected outcomes
20
+ * (non-interactive shell, user declines, cancels, or a failed probe) — returns
21
+ * quietly so a first-run `agents setup` is never blocked by the optional step.
22
+ */
23
+ export declare function promptAndProvisionSessionSync(opts?: ProvisionFlowOptions): Promise<void>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Interactive provisioning flow for cross-machine session sync. Collects R2
3
+ * credentials, writes the `r2.backups` bundle (via the pure lib helper), probes
4
+ * connectivity, and opts the machine into the `session-sync` beta. Shared by
5
+ * `agents setup` (offered, opt-in) and `agents sessions sync --setup` (explicit).
6
+ *
7
+ * The prompt/UI lives here in the command layer; the credential-writing and
8
+ * connectivity logic is the pure, unit-tested `lib/session/sync/provision.ts`.
9
+ */
10
+ import chalk from 'chalk';
11
+ import ora from 'ora';
12
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
13
+ import { isSyncConfigured } from '../lib/session/sync/config.js';
14
+ import { writeSyncBundle, probeR2Connectivity, readStoredEncKey } from '../lib/session/sync/provision.js';
15
+ import { setBetaEnabled } from '../lib/beta.js';
16
+ const SYNC_BETA = 'session-sync';
17
+ const nonEmpty = (v) => (v.trim().length > 0 ? true : 'required');
18
+ /**
19
+ * Run the guided session-sync setup. Never throws for expected outcomes
20
+ * (non-interactive shell, user declines, cancels, or a failed probe) — returns
21
+ * quietly so a first-run `agents setup` is never blocked by the optional step.
22
+ */
23
+ export async function promptAndProvisionSessionSync(opts = {}) {
24
+ if (!isInteractiveTerminal()) {
25
+ if (opts.explicit)
26
+ console.error(chalk.red('Session-sync setup needs an interactive terminal.'));
27
+ return;
28
+ }
29
+ const alreadyConfigured = isSyncConfigured();
30
+ const { confirm, input, password } = await import('@inquirer/prompts');
31
+ try {
32
+ if (!opts.explicit) {
33
+ if (alreadyConfigured)
34
+ return; // first-run setup: nothing to do
35
+ const want = await confirm({
36
+ message: 'Set up cross-machine session sync (encrypted transcripts via Cloudflare R2)?',
37
+ default: false,
38
+ });
39
+ if (!want)
40
+ return;
41
+ }
42
+ else if (alreadyConfigured) {
43
+ const reconfigure = await confirm({
44
+ message: 'Session sync is already configured. Re-enter R2 credentials?',
45
+ default: false,
46
+ });
47
+ if (!reconfigure) {
48
+ const key = readStoredEncKey();
49
+ if (key) {
50
+ console.log(chalk.dim('\nShared encryption key (paste on every other machine you sync):'));
51
+ console.log(' ' + chalk.cyan(key));
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ console.log(chalk.dim('\nR2 credentials — Cloudflare dashboard > R2 > Manage API Tokens (needs read+write):'));
57
+ const accountId = (await input({ message: 'R2 account ID:', validate: nonEmpty })).trim();
58
+ const bucketName = (await input({ message: 'R2 bucket name:', validate: nonEmpty })).trim();
59
+ const accessKeyId = (await password({ message: 'R2 access key ID:', mask: true })).trim();
60
+ const secretAccessKey = (await password({ message: 'R2 secret access key:', mask: true })).trim();
61
+ const endpoint = (await input({
62
+ message: 'S3 endpoint override (blank = Cloudflare R2):',
63
+ default: '',
64
+ })).trim();
65
+ // Encryption key: the first machine mints one; every other machine pastes it
66
+ // so the whole fabric shares a single key (and can decrypt each other).
67
+ const firstMachine = await confirm({ message: 'Is this the FIRST machine in your sync fabric?', default: true });
68
+ let encKey;
69
+ if (!firstMachine) {
70
+ encKey = (await password({
71
+ message: 'Paste the shared R2_SYNC_ENC_KEY from your first machine:',
72
+ mask: true,
73
+ validate: nonEmpty,
74
+ })).trim();
75
+ }
76
+ const { encKeyAction } = writeSyncBundle({
77
+ accountId, bucketName, accessKeyId, secretAccessKey,
78
+ endpoint: endpoint || undefined,
79
+ encKey: encKey || undefined,
80
+ });
81
+ const spinner = ora('Validating R2 read+write...').start();
82
+ const probe = await probeR2Connectivity();
83
+ if (!probe.ok) {
84
+ spinner.fail(`R2 probe failed: ${probe.error}`);
85
+ console.log(chalk.yellow('Credentials were saved to the r2.backups bundle but not verified. Fix them and re-run ' +
86
+ '`agents sessions sync --setup`, or rotate one key with `agents secrets rotate r2.backups <KEY>`.'));
87
+ return; // do not auto-enable sync against credentials that don't work
88
+ }
89
+ spinner.succeed('R2 read+write verified.');
90
+ setBetaEnabled([SYNC_BETA], true);
91
+ console.log(chalk.green('\nSession sync configured and enabled') + chalk.dim(' (the daemon syncs every ~90s).'));
92
+ if (encKeyAction === 'generated') {
93
+ const key = readStoredEncKey();
94
+ console.log(chalk.bold('\nShared encryption key — paste this on every OTHER machine you sync:'));
95
+ console.log(' ' + chalk.cyan(key ?? '(unavailable)'));
96
+ console.log(chalk.dim('Anyone with this key can decrypt your transcripts. Keep it secret.'));
97
+ }
98
+ console.log(chalk.dim('\nSync now with: agents sessions sync'));
99
+ }
100
+ catch (err) {
101
+ if (isPromptCancelled(err)) {
102
+ console.log(chalk.yellow('\nSession-sync setup cancelled.'));
103
+ return;
104
+ }
105
+ throw err;
106
+ }
107
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `agents webhook` — localhost receiver for signed public webhook ingress.
3
+ *
4
+ * The receiver intentionally binds localhost by default. Public exposure is a
5
+ * separate `agents funnel up <host>` step so the HTTP process can be tested and
6
+ * rotated without changing the Tailscale Funnel config.
7
+ */
8
+ import type { Command } from 'commander';
9
+ export declare function registerWebhookCommand(program: Command): void;
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
3
+ import { startWebhookServer } from '../lib/triggers/webhook.js';
4
+ const DEFAULT_HOST = '127.0.0.1';
5
+ const DEFAULT_PORT = 8787;
6
+ function positiveInt(value, fallback) {
7
+ const parsed = Number.parseInt(value ?? '', 10);
8
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
9
+ }
10
+ function readWebhookSecrets(bundleName) {
11
+ const { env } = readAndResolveBundleEnv(bundleName, {
12
+ caller: 'webhook serve',
13
+ });
14
+ const secrets = {};
15
+ if (env.GITHUB_WEBHOOK_SECRET)
16
+ secrets.github = env.GITHUB_WEBHOOK_SECRET;
17
+ if (env.LINEAR_WEBHOOK_SECRET)
18
+ secrets.linear = env.LINEAR_WEBHOOK_SECRET;
19
+ if (!secrets.github && !secrets.linear) {
20
+ throw new Error(`Bundle '${bundleName}' must contain GITHUB_WEBHOOK_SECRET or LINEAR_WEBHOOK_SECRET.`);
21
+ }
22
+ return secrets;
23
+ }
24
+ function waitForListening(server) {
25
+ if (server.listening)
26
+ return Promise.resolve();
27
+ return new Promise((resolve, reject) => {
28
+ const cleanup = () => {
29
+ server.off('listening', onListening);
30
+ server.off('error', onError);
31
+ };
32
+ const onListening = () => {
33
+ cleanup();
34
+ resolve();
35
+ };
36
+ const onError = (err) => {
37
+ cleanup();
38
+ reject(err);
39
+ };
40
+ server.once('listening', onListening);
41
+ server.once('error', onError);
42
+ });
43
+ }
44
+ export function registerWebhookCommand(program) {
45
+ const webhook = program
46
+ .command('webhook')
47
+ .description('Run a localhost signed webhook receiver for routine triggers.');
48
+ webhook
49
+ .command('serve')
50
+ .description('Receive signed GitHub/Linear webhooks on /hooks/<source> and fire matching routines.')
51
+ .requiredOption('--secrets-bundle <name>', 'agents secrets bundle containing GITHUB_WEBHOOK_SECRET and/or LINEAR_WEBHOOK_SECRET')
52
+ .option('--host <addr>', `Bind address (default ${DEFAULT_HOST})`, DEFAULT_HOST)
53
+ .option('-p, --port <n>', `Local port (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
54
+ .option('--rate-limit <n>', 'Accepted deliveries per source per minute', '60')
55
+ .action(async (opts) => {
56
+ let secrets;
57
+ try {
58
+ secrets = readWebhookSecrets(opts.secretsBundle);
59
+ }
60
+ catch (err) {
61
+ console.error(chalk.red(err.message));
62
+ process.exit(1);
63
+ }
64
+ const port = positiveInt(opts.port, DEFAULT_PORT);
65
+ const rateLimit = positiveInt(opts.rateLimit, 60);
66
+ try {
67
+ const server = startWebhookServer({
68
+ host: opts.host ?? DEFAULT_HOST,
69
+ port,
70
+ secrets,
71
+ rateLimitPerMinute: rateLimit,
72
+ onDelivery: (webhook, fired) => {
73
+ console.log(`${new Date().toISOString()} ${webhook.source}:${webhook.event} ` +
74
+ `${fired.length ? `fired ${fired.map((f) => f.jobName).join(', ')}` : 'no match'}`);
75
+ },
76
+ });
77
+ await waitForListening(server);
78
+ const address = server.address();
79
+ const bound = typeof address === 'object' && address ? address.port : port;
80
+ console.log(`${chalk.green('agents webhook')} ${chalk.dim('→')} ${chalk.cyan(`http://${opts.host ?? DEFAULT_HOST}:${bound}`)}`);
81
+ console.log(chalk.dim('signed · localhost by default · endpoints: /hooks/github, /hooks/linear · Ctrl-C to stop'));
82
+ const shutdown = () => {
83
+ server.close(() => process.exit(0));
84
+ };
85
+ process.on('SIGINT', shutdown);
86
+ process.on('SIGTERM', shutdown);
87
+ }
88
+ catch (err) {
89
+ console.error(chalk.red(`Could not start webhook receiver: ${err.message}`));
90
+ process.exit(1);
91
+ }
92
+ });
93
+ }