@phnx-labs/agents-cli 1.20.61 → 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.
- package/CHANGELOG.md +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- 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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 --
|
|
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
|
|
690
|
-
.
|
|
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
|
|
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);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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'));
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
.
|
|
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
|
+
}
|