@phnx-labs/agents-cli 1.22.14 → 1.22.16
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 +53 -0
- package/README.md +7 -5
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +4 -6
- package/dist/commands/exec.js +96 -11
- package/dist/commands/humans.d.ts +10 -0
- package/dist/commands/humans.js +91 -0
- package/dist/commands/packages.js +90 -22
- package/dist/commands/projects.d.ts +2 -5
- package/dist/commands/projects.js +92 -65
- package/dist/commands/resume.d.ts +17 -0
- package/dist/commands/resume.js +80 -0
- package/dist/commands/routines.js +53 -71
- package/dist/commands/sessions.d.ts +13 -4
- package/dist/commands/sessions.js +37 -19
- package/dist/commands/setup-watchdog.d.ts +3 -0
- package/dist/commands/setup-watchdog.js +54 -0
- package/dist/commands/setup.js +8 -1
- package/dist/commands/ssh.d.ts +11 -0
- package/dist/commands/ssh.js +18 -4
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +40 -28
- package/dist/index.js +3 -2
- package/dist/lib/auto-dispatch.d.ts +8 -10
- package/dist/lib/auto-dispatch.js +20 -42
- package/dist/lib/channels/send.d.ts +4 -1
- package/dist/lib/channels/send.js +15 -7
- package/dist/lib/devices/doctor-findings.d.ts +14 -0
- package/dist/lib/devices/doctor-findings.js +92 -51
- package/dist/lib/exec.d.ts +6 -2
- package/dist/lib/exec.js +22 -4
- package/dist/lib/hooks.d.ts +4 -2
- package/dist/lib/hooks.js +173 -23
- package/dist/lib/humans.d.ts +25 -0
- package/dist/lib/humans.js +65 -0
- package/dist/lib/memory.js +2 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +116 -7
- package/dist/lib/notify.js +6 -2
- package/dist/lib/permissions.js +12 -10
- package/dist/lib/project-import.d.ts +7 -46
- package/dist/lib/project-import.js +9 -108
- package/dist/lib/projects.d.ts +20 -3
- package/dist/lib/projects.js +36 -18
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +11 -0
- package/dist/lib/routine-activation.d.ts +14 -0
- package/dist/lib/routine-activation.js +78 -0
- package/dist/lib/routines-project.d.ts +1 -1
- package/dist/lib/routines-project.js +3 -9
- package/dist/lib/routines.d.ts +3 -1
- package/dist/lib/routines.js +32 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +11 -0
- package/dist/lib/secrets/bundles.js +122 -5
- package/dist/lib/session/actor-sidecar.d.ts +5 -2
- package/dist/lib/session/actor-sidecar.js +4 -2
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +19 -3
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/staleness/writers/sources.d.ts +6 -1
- package/dist/lib/staleness/writers/sources.js +93 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +23 -10
- package/dist/lib/types.d.ts +64 -1
- package/dist/lib/versions.js +48 -14
- package/dist/lib/watchdog/rotate.d.ts +1 -1
- package/dist/lib/watchdog/rotate.js +1 -1
- package/package.json +1 -1
- package/dist/lib/watchdog/routine.d.ts +0 -44
- package/dist/lib/watchdog/routine.js +0 -69
|
@@ -20,16 +20,15 @@ import { machineId } from '../lib/machine-id.js';
|
|
|
20
20
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
21
21
|
import { gatherRemoteActive } from '../lib/session/remote-active.js';
|
|
22
22
|
import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
23
|
-
import { factoryProjectsPath } from '../lib/auto-dispatch.js';
|
|
24
23
|
import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
|
|
25
|
-
import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
|
|
24
|
+
import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, validateProjectDef, } from '../lib/projects.js';
|
|
26
25
|
import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } from '../lib/project-status.js';
|
|
27
26
|
import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
|
|
28
27
|
import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
|
|
29
28
|
import { checkRepoSlug } from '../lib/project-doctor.js';
|
|
30
29
|
import { formatFocusAreas, readFocusAreas } from '../lib/project-focus.js';
|
|
31
30
|
import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
|
|
32
|
-
import {
|
|
31
|
+
import { buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
|
|
33
32
|
/** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
|
|
34
33
|
export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
|
|
35
34
|
/** Max peers named in the skipped note before the rest collapse to `+N`. */
|
|
@@ -73,11 +72,8 @@ function parseGoalFlag(raw) {
|
|
|
73
72
|
*
|
|
74
73
|
* `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
|
|
75
74
|
* makes git print `error: No such remote 'origin'` on ITS stderr, which is the
|
|
76
|
-
* terminal's — the catch below never sees it.
|
|
77
|
-
*
|
|
78
|
-
* `import --from-factory` started calling it per row: importing 12 registry
|
|
79
|
-
* rows printed two raw git errors between the progress lines. Absence of a
|
|
80
|
-
* remote is an expected answer here (`undefined`), not something to report.
|
|
75
|
+
* terminal's — the catch below never sees it. Absence of a remote is an
|
|
76
|
+
* expected answer here (`undefined`), not something to report.
|
|
81
77
|
*/
|
|
82
78
|
export function originSlug(cwd) {
|
|
83
79
|
try {
|
|
@@ -92,38 +88,6 @@ export function originSlug(cwd) {
|
|
|
92
88
|
return undefined;
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
|
-
/**
|
|
96
|
-
* Read the Factory registry off disk and plan its import. Every read failure is
|
|
97
|
-
* fatal and named — an unreadable or malformed registry must not read as "0
|
|
98
|
-
* projects to import".
|
|
99
|
-
*/
|
|
100
|
-
function runFactoryImport(existing, opts) {
|
|
101
|
-
const src = factoryProjectsPath();
|
|
102
|
-
let rawText;
|
|
103
|
-
try {
|
|
104
|
-
rawText = fs.readFileSync(src, 'utf8');
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
console.error(chalk.red(`No Factory registry at ${src}`));
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
let parsed;
|
|
111
|
-
try {
|
|
112
|
-
parsed = JSON.parse(rawText);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
const rows = Array.isArray(parsed)
|
|
119
|
-
? parsed
|
|
120
|
-
: Array.isArray(parsed?.projects)
|
|
121
|
-
? parsed.projects
|
|
122
|
-
: [];
|
|
123
|
-
// The checkout's real `origin` overrides the registry's path-derived slug —
|
|
124
|
-
// see buildFactoryImportCandidates for why the path guess is not trustworthy.
|
|
125
|
-
return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
|
|
126
|
-
}
|
|
127
91
|
/**
|
|
128
92
|
* List the workspace's Linear projects and plan their import, binding each to a
|
|
129
93
|
* local checkout under the configured projects root when the names match
|
|
@@ -442,7 +406,11 @@ export function registerProjectsCommands(program) {
|
|
|
442
406
|
examples: `
|
|
443
407
|
agents projects import --from-linear # the projects you actually track
|
|
444
408
|
agents projects add rush --repo phnx-labs/rush --path apps/web
|
|
445
|
-
agents projects list
|
|
409
|
+
agents projects list # definitions only (no session scan)
|
|
410
|
+
agents projects list --with-agents # opt-in local active counts
|
|
411
|
+
agents projects list --json # machine-readable defs (Factory uses this)
|
|
412
|
+
echo '{...}' | agents projects save --json # create/update one def from stdin
|
|
413
|
+
agents projects rm rush --json # machine-readable delete
|
|
446
414
|
agents projects status # every project, across the whole fleet
|
|
447
415
|
agents projects status rush # one project (same body as view/show)
|
|
448
416
|
agents projects view rush # alias of status <name>
|
|
@@ -452,31 +420,34 @@ export function registerProjectsCommands(program) {
|
|
|
452
420
|
`,
|
|
453
421
|
notes: `
|
|
454
422
|
Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
|
|
455
|
-
machines with 'agents push/pull'.
|
|
456
|
-
|
|
457
|
-
'import --from-factory' reads Factory's auto-detected registry, which
|
|
458
|
-
guesses from checkouts on disk — it imports only 'high' confidence rows by
|
|
459
|
-
default. Widen with --min-confidence medium or --all, and drop a bad guess
|
|
460
|
-
with 'agents projects rm <name>'.
|
|
423
|
+
machines with 'agents push/pull'. Factory reads and writes only through
|
|
424
|
+
these commands — never ~/.agents/factory/projects.json.
|
|
461
425
|
`,
|
|
462
426
|
});
|
|
463
427
|
// ---- list ----
|
|
464
428
|
projects
|
|
465
429
|
.command('list')
|
|
466
|
-
.description('List defined projects
|
|
430
|
+
.description('List defined projects (definitions only by default; no session scan).')
|
|
467
431
|
.option('--json', 'Machine-readable output')
|
|
432
|
+
.option('--with-agents', 'Include local active agent counts (opt-in; never SSH)')
|
|
468
433
|
.action(async (opts) => {
|
|
469
434
|
const defs = listProjectDefs();
|
|
435
|
+
// Definitions-only by default: zero session scan / SSH. --with-agents is
|
|
436
|
+
// an explicit opt-in for local active counts only (getActiveSessions is
|
|
437
|
+
// local; it never fans out).
|
|
438
|
+
const roll = opts.withAgents
|
|
439
|
+
? rollupSessionsByProject(defs, await getActiveSessions())
|
|
440
|
+
: undefined;
|
|
470
441
|
if (opts.json) {
|
|
471
|
-
|
|
472
|
-
|
|
442
|
+
console.log(JSON.stringify(opts.withAgents
|
|
443
|
+
? defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 }))
|
|
444
|
+
: defs, null, 2));
|
|
473
445
|
return;
|
|
474
446
|
}
|
|
475
447
|
if (!defs.length) {
|
|
476
448
|
console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
|
|
477
449
|
return;
|
|
478
450
|
}
|
|
479
|
-
const roll = rollupSessionsByProject(defs, await getActiveSessions());
|
|
480
451
|
const rows = defs.map((d) => ({
|
|
481
452
|
name: d.name,
|
|
482
453
|
path: truncate(d.root ?? d.defaultPath ?? '', LIST_PATH_MAX),
|
|
@@ -484,9 +455,9 @@ export function registerProjectsCommands(program) {
|
|
|
484
455
|
}));
|
|
485
456
|
const w = computeProjectListWidths(rows);
|
|
486
457
|
for (const [i, d] of defs.entries()) {
|
|
487
|
-
const agents = roll.get(d.name)?.agents ?? 0;
|
|
488
458
|
const row = rows[i];
|
|
489
|
-
|
|
459
|
+
const agentsSuffix = opts.withAgents && roll ? ` ${roll.get(d.name)?.agents ?? 0} agents` : '';
|
|
460
|
+
console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
|
|
490
461
|
}
|
|
491
462
|
});
|
|
492
463
|
// ---- add ----
|
|
@@ -732,11 +703,8 @@ export function registerProjectsCommands(program) {
|
|
|
732
703
|
// ---- import ----
|
|
733
704
|
projects
|
|
734
705
|
.command('import')
|
|
735
|
-
.description('Import project definitions from Linear (
|
|
736
|
-
.option('--from-linear', 'Import the workspace\'s Linear projects
|
|
737
|
-
.option('--from-factory', 'Import the Factory registry (~/.agents/factory/projects.json)')
|
|
738
|
-
.option('--min-confidence <level>', '--from-factory only: lowest detection confidence to import — low|medium|high (default: high)')
|
|
739
|
-
.option('--all', '--from-factory only: import every row regardless of confidence')
|
|
706
|
+
.description('Import project definitions from Linear (via the `linear` CLI).')
|
|
707
|
+
.option('--from-linear', 'Import the workspace\'s Linear projects')
|
|
740
708
|
.option('--force', 'Overwrite existing definitions')
|
|
741
709
|
.action((raw) => {
|
|
742
710
|
let opts;
|
|
@@ -748,7 +716,7 @@ export function registerProjectsCommands(program) {
|
|
|
748
716
|
process.exit(1);
|
|
749
717
|
}
|
|
750
718
|
const existing = new Map(listProjectDefs().map((d) => [d.name, d]));
|
|
751
|
-
const result =
|
|
719
|
+
const result = runLinearImport(existing, opts);
|
|
752
720
|
for (const def of result.defs)
|
|
753
721
|
writeProjectDef(def);
|
|
754
722
|
const n = result.defs.length;
|
|
@@ -756,9 +724,6 @@ export function registerProjectsCommands(program) {
|
|
|
756
724
|
console.log(chalk.green(`Imported ${n} project${n === 1 ? '' : 's'}${s ? chalk.gray(` (${s} skipped)`) : ''}`));
|
|
757
725
|
for (const skip of result.skipped)
|
|
758
726
|
console.log(chalk.gray(` skip ${skip.name}: ${skip.reason}`));
|
|
759
|
-
if (opts.source === 'factory' && s > 0 && opts.minConfidence === 'high') {
|
|
760
|
-
console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
|
|
761
|
-
}
|
|
762
727
|
});
|
|
763
728
|
// ---- set ----
|
|
764
729
|
projects
|
|
@@ -883,17 +848,79 @@ export function registerProjectsCommands(program) {
|
|
|
883
848
|
writeProjectDef(def);
|
|
884
849
|
console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
|
|
885
850
|
});
|
|
851
|
+
// ---- save ----
|
|
852
|
+
projects
|
|
853
|
+
.command('save')
|
|
854
|
+
.description('Create or update one project from a complete ProjectDef JSON object on stdin.')
|
|
855
|
+
.option('--json', 'Required: read ProjectDef JSON from stdin; print the saved definition as JSON')
|
|
856
|
+
.action(async (opts) => {
|
|
857
|
+
if (!opts.json) {
|
|
858
|
+
console.error(chalk.red('projects save requires --json (pipe one complete ProjectDef JSON object on stdin).'));
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
const chunks = [];
|
|
862
|
+
for await (const c of process.stdin)
|
|
863
|
+
chunks.push(c);
|
|
864
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
865
|
+
if (!raw) {
|
|
866
|
+
console.error(chalk.red('projects save --json: empty stdin (expected one ProjectDef JSON object).'));
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
let parsed;
|
|
870
|
+
try {
|
|
871
|
+
parsed = JSON.parse(raw);
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
console.error(chalk.red(`projects save --json: invalid JSON: ${e instanceof Error ? e.message : String(e)}`));
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
let def;
|
|
878
|
+
try {
|
|
879
|
+
def = validateProjectDef(parsed);
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
writeProjectDef(def);
|
|
886
|
+
const saved = loadProjectDef(def.name);
|
|
887
|
+
if (!saved) {
|
|
888
|
+
console.error(chalk.red(`projects save: wrote "${def.name}" but could not reload it`));
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
console.log(JSON.stringify(saved, null, 2));
|
|
892
|
+
});
|
|
886
893
|
// ---- rm ----
|
|
887
894
|
projects
|
|
888
895
|
.command('rm <name>')
|
|
889
896
|
.alias('remove')
|
|
890
897
|
.description('Delete a project definition. Never touches the repo.')
|
|
891
|
-
.
|
|
898
|
+
.option('--json', 'Machine-readable success / error')
|
|
899
|
+
.action((name, opts) => {
|
|
900
|
+
if (!isSafeProjectName(name)) {
|
|
901
|
+
if (opts.json) {
|
|
902
|
+
console.log(JSON.stringify({ ok: false, name, error: `Invalid project name: "${name}"` }));
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
console.error(chalk.red(`Invalid project name: "${name}"`));
|
|
906
|
+
}
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
892
909
|
if (removeProjectDef(name)) {
|
|
893
|
-
|
|
910
|
+
if (opts.json) {
|
|
911
|
+
console.log(JSON.stringify({ ok: true, name, removed: true }));
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
console.log(chalk.green(`Removed project "${name}"`));
|
|
915
|
+
}
|
|
894
916
|
}
|
|
895
917
|
else {
|
|
896
|
-
|
|
918
|
+
if (opts.json) {
|
|
919
|
+
console.log(JSON.stringify({ ok: false, name, error: `No project named "${name}"` }));
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
console.error(chalk.red(`No project named "${name}".`));
|
|
923
|
+
}
|
|
897
924
|
process.exit(1);
|
|
898
925
|
}
|
|
899
926
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
interface ResumeOptions {
|
|
3
|
+
mode?: string;
|
|
4
|
+
interactive?: boolean;
|
|
5
|
+
headless?: boolean;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
quiet?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Translate `agents resume <id>` to the canonical `agents run` surface after
|
|
10
|
+
* resolving the source identity. The run command remains the only executor. */
|
|
11
|
+
export declare function buildResumeRunArgs(session: {
|
|
12
|
+
id: string;
|
|
13
|
+
agent: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
}, prompt: string | undefined, options: ResumeOptions): string[];
|
|
16
|
+
export declare function registerResumeCommand(program: Command): void;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Strict session resume convenience command. */
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { setHelpSections } from '../lib/help.js';
|
|
5
|
+
import { resolveSessionMetadataValue } from './sessions.js';
|
|
6
|
+
/** Translate `agents resume <id>` to the canonical `agents run` surface after
|
|
7
|
+
* resolving the source identity. The run command remains the only executor. */
|
|
8
|
+
export function buildResumeRunArgs(session, prompt, options) {
|
|
9
|
+
const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
|
|
10
|
+
const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--resume', session.id];
|
|
11
|
+
if (options.mode)
|
|
12
|
+
args.push('--mode', options.mode);
|
|
13
|
+
if (options.interactive)
|
|
14
|
+
args.push('--interactive');
|
|
15
|
+
if (options.headless)
|
|
16
|
+
args.push('--headless');
|
|
17
|
+
if (options.cwd)
|
|
18
|
+
args.push('--cwd', options.cwd);
|
|
19
|
+
if (options.quiet)
|
|
20
|
+
args.push('--quiet');
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
23
|
+
export function registerResumeCommand(program) {
|
|
24
|
+
const cmd = program
|
|
25
|
+
.command('resume <session-id> [prompt]')
|
|
26
|
+
.description('Resume a session with its original harness, version, device, account, cwd, and mode')
|
|
27
|
+
.option('-m, --mode <mode>', 'Override the recorded launch mode')
|
|
28
|
+
.option('-i, --interactive', 'Resume interactively even when a prompt is provided')
|
|
29
|
+
.option('--headless', 'Resume headlessly (a prompt is required)')
|
|
30
|
+
.option('--cwd <path>', 'Override the recorded working directory')
|
|
31
|
+
.option('-q, --quiet', 'Suppress routing banners')
|
|
32
|
+
.action(async (sessionId, prompt, options) => {
|
|
33
|
+
const outcome = await resolveSessionMetadataValue(sessionId.trim());
|
|
34
|
+
if (outcome.kind === 'partial') {
|
|
35
|
+
console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
|
|
36
|
+
process.exitCode = 2;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (outcome.kind === 'not-found') {
|
|
40
|
+
console.error(chalk.red(`No session matching "${sessionId}".`));
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (outcome.kind === 'ambiguous') {
|
|
45
|
+
console.error(chalk.red(`"${sessionId}" matches ${outcome.candidates.length} sessions. Pass the full session id.`));
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const args = buildResumeRunArgs(outcome.session, prompt, options);
|
|
50
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
// Avoid repeating the fleet lookup in the delegated local `run`
|
|
55
|
+
// process. The value is metadata-only and is not forwarded over SSH;
|
|
56
|
+
// the owner performs its own local SQLite lookup.
|
|
57
|
+
AGENTS_RESUME_SOURCE_JSON: JSON.stringify(outcome.session),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const exitCode = await new Promise((resolve) => {
|
|
61
|
+
child.once('error', () => resolve(127));
|
|
62
|
+
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
63
|
+
});
|
|
64
|
+
process.exitCode = exitCode;
|
|
65
|
+
});
|
|
66
|
+
setHelpSections(cmd, {
|
|
67
|
+
examples: `
|
|
68
|
+
# Resume by full id
|
|
69
|
+
agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897
|
|
70
|
+
|
|
71
|
+
# Resume and continue headlessly
|
|
72
|
+
agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 "finish the tests"
|
|
73
|
+
|
|
74
|
+
# Deliberately change permissions
|
|
75
|
+
agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 --mode edit`,
|
|
76
|
+
notes: `
|
|
77
|
+
Full IDs resolve from the local session database first; an SSH fleet lookup runs only after a local miss.
|
|
78
|
+
Use agents run auto --resume <id> when the original account is unavailable and another harness may continue.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -14,7 +14,7 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
|
|
|
14
14
|
import { assertSchedulerEnabled } from '../lib/device-config.js';
|
|
15
15
|
import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
16
16
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
17
|
-
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy,
|
|
17
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
18
18
|
import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
|
|
19
19
|
import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
|
|
20
20
|
import { getRoutinesDir } from '../lib/state.js';
|
|
@@ -29,6 +29,9 @@ import { setHelpSections } from '../lib/help.js';
|
|
|
29
29
|
import { loadDevices, loadDevicesSync } from '../lib/devices/registry.js';
|
|
30
30
|
import { machineId, normalizeHost } from '../lib/machine-id.js';
|
|
31
31
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
32
|
+
import { devicesWithRoutineEnabled } from '../lib/routine-activation.js';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { getCliLaunch } from '../lib/cli-entry.js';
|
|
32
35
|
/**
|
|
33
36
|
* Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
|
|
34
37
|
* when it hasn't completed or timestamps are unparseable. Leading separator lets
|
|
@@ -131,7 +134,7 @@ function placementTag(job) {
|
|
|
131
134
|
return '->cloud';
|
|
132
135
|
}
|
|
133
136
|
function deviceLabel(job, width) {
|
|
134
|
-
const full = [job.
|
|
137
|
+
const full = [devicesWithRoutineEnabled(job.name).join(','), placementTag(job)]
|
|
135
138
|
.filter(Boolean)
|
|
136
139
|
.join(' ');
|
|
137
140
|
const raw = full.length === 0 ? 'all' : full;
|
|
@@ -182,9 +185,9 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
|
|
|
182
185
|
add(`host:${normalizeHost(host)}`, titleWithDeviceState(`Host: ${host}`, host, registry), job);
|
|
183
186
|
continue;
|
|
184
187
|
}
|
|
185
|
-
const devices = job.
|
|
188
|
+
const devices = devicesWithRoutineEnabled(job.name);
|
|
186
189
|
if (devices.length === 0) {
|
|
187
|
-
add('
|
|
190
|
+
add('disabled', 'Disabled', job);
|
|
188
191
|
continue;
|
|
189
192
|
}
|
|
190
193
|
for (const device of devices) {
|
|
@@ -202,12 +205,14 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
|
|
|
202
205
|
return 0;
|
|
203
206
|
if (group.key === 'fleet')
|
|
204
207
|
return 1;
|
|
205
|
-
if (group.key === '
|
|
208
|
+
if (group.key === 'disabled')
|
|
206
209
|
return 2;
|
|
207
|
-
if (group.key
|
|
210
|
+
if (group.key === 'cloud')
|
|
208
211
|
return 3;
|
|
209
|
-
if (group.key.startsWith('
|
|
212
|
+
if (group.key.startsWith('device:'))
|
|
210
213
|
return 4;
|
|
214
|
+
if (group.key.startsWith('host:'))
|
|
215
|
+
return 5;
|
|
211
216
|
return 5;
|
|
212
217
|
};
|
|
213
218
|
return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
|
|
@@ -545,6 +550,7 @@ export function registerRoutinesCommands(program) {
|
|
|
545
550
|
const nowJson = new Date();
|
|
546
551
|
const payload = jobs.map((job) => {
|
|
547
552
|
const latestRun = localLatestRun(job);
|
|
553
|
+
const enabledDevices = devicesWithRoutineEnabled(job.name);
|
|
548
554
|
return {
|
|
549
555
|
name: job.name,
|
|
550
556
|
agent: job.agent ?? null,
|
|
@@ -555,7 +561,8 @@ export function registerRoutinesCommands(program) {
|
|
|
555
561
|
scheduleHuman: fireConditionLabel(job),
|
|
556
562
|
trigger: job.trigger ?? null,
|
|
557
563
|
timezone: job.timezone ?? null,
|
|
558
|
-
devices:
|
|
564
|
+
devices: enabledDevices,
|
|
565
|
+
enabledDevices,
|
|
559
566
|
host: job.host ?? null,
|
|
560
567
|
hostStrategy: resolveHostStrategy(job),
|
|
561
568
|
source: job.source ?? null,
|
|
@@ -628,7 +635,7 @@ export function registerRoutinesCommands(program) {
|
|
|
628
635
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
629
636
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
630
637
|
.option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
|
|
631
|
-
.option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host.
|
|
638
|
+
.option('--run-on <name>', 'BODY placement: execute the job body on this machine over SSH (registered host, device, capability tag, or user@host). Sets hostStrategy=host. Same model as agents run --where device:<name> (docs/00-concepts.md#placement).')
|
|
632
639
|
.option('--placement <strategy>', `BODY placement strategy: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Maps to the shared Placement model (local|device|fleet|cloud). Not the same as --host (which manages routines on a remote machine).`)
|
|
633
640
|
.option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
|
|
634
641
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
@@ -727,15 +734,6 @@ export function registerRoutinesCommands(program) {
|
|
|
727
734
|
console.error(chalk.red('--placement host requires --run-on <name>'));
|
|
728
735
|
process.exit(1);
|
|
729
736
|
}
|
|
730
|
-
// Off-box placement without a --devices pin would fire on EVERY daemon
|
|
731
|
-
// in the fleet, each dispatching once — duplicate runs (RUSH-1980).
|
|
732
|
-
// Pin to this machine unless the user chose an explicit eligibility set.
|
|
733
|
-
const strategyForPin = hostStrategy
|
|
734
|
-
?? (options.runOn ? 'host' : 'local');
|
|
735
|
-
if (placementRequiresFiringPin(strategyForPin) && !devices) {
|
|
736
|
-
devices = [machineId()];
|
|
737
|
-
console.error(chalk.gray(`--placement ${strategyForPin} with no --devices: pinned firing to this machine (${devices[0]}).`));
|
|
738
|
-
}
|
|
739
737
|
const config = {
|
|
740
738
|
name: nameOrPath,
|
|
741
739
|
...(schedule ? { schedule } : {}),
|
|
@@ -767,6 +765,7 @@ export function registerRoutinesCommands(program) {
|
|
|
767
765
|
process.exit(1);
|
|
768
766
|
}
|
|
769
767
|
writeJob(config);
|
|
768
|
+
setJobEnabled(config.name, config.enabled && (!devices || devices.map(normalizeHost).includes(normalizeHost(machineId()))));
|
|
770
769
|
if (options.json) {
|
|
771
770
|
writeJson({
|
|
772
771
|
ok: true,
|
|
@@ -831,14 +830,8 @@ export function registerRoutinesCommands(program) {
|
|
|
831
830
|
config.runOnce = true;
|
|
832
831
|
console.error(chalk.yellow(`Schedule "${config.schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
|
|
833
832
|
}
|
|
834
|
-
// Same duplicate-fire guard as --placement/--run-on: off-box placement
|
|
835
|
-
// with no eligibility pin would fire from every daemon in the fleet.
|
|
836
|
-
const fileStrategy = resolveHostStrategy(config);
|
|
837
|
-
if (placementRequiresFiringPin(fileStrategy) && (!config.devices || config.devices.length === 0)) {
|
|
838
|
-
config.devices = [machineId()];
|
|
839
|
-
console.error(chalk.gray(`${fileStrategy} placement with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
|
|
840
|
-
}
|
|
841
833
|
writeJob(config);
|
|
834
|
+
setJobEnabled(config.name, config.enabled && (!config.devices || config.devices.map(normalizeHost).includes(normalizeHost(machineId()))));
|
|
842
835
|
if (options.json) {
|
|
843
836
|
writeJson({
|
|
844
837
|
ok: true,
|
|
@@ -1435,12 +1428,13 @@ export function registerRoutinesCommands(program) {
|
|
|
1435
1428
|
process.exit(1);
|
|
1436
1429
|
}
|
|
1437
1430
|
});
|
|
1438
|
-
//
|
|
1431
|
+
// Device activation management for a single routine. Each mutation executes
|
|
1432
|
+
// on the target device so only that host writes devices/<hostname>/agents.yaml.
|
|
1439
1433
|
routinesCmd
|
|
1440
1434
|
.command('devices [name]')
|
|
1441
|
-
.description('View or change
|
|
1442
|
-
.option('--set <devices>', 'Replace the
|
|
1443
|
-
.option('--clear', '
|
|
1435
|
+
.description('View or change the devices where a routine is enabled. Without flags, opens an interactive picker (requires a TTY).')
|
|
1436
|
+
.option('--set <devices>', 'Replace the enabled-device set with this comma-separated list')
|
|
1437
|
+
.option('--clear', 'Disable the routine on every registered device')
|
|
1444
1438
|
.action(async (name, options) => {
|
|
1445
1439
|
const hasSet = options.set !== undefined;
|
|
1446
1440
|
if (hasSet && options.clear) {
|
|
@@ -1457,33 +1451,37 @@ export function registerRoutinesCommands(program) {
|
|
|
1457
1451
|
console.log(chalk.red(`Job '${name}' not found`));
|
|
1458
1452
|
process.exit(1);
|
|
1459
1453
|
}
|
|
1454
|
+
const applyDevices = async (selected) => {
|
|
1455
|
+
const registry = await loadDevices();
|
|
1456
|
+
const registered = Object.keys(registry).map(normalizeHost);
|
|
1457
|
+
const all = [...new Set([...registered, machineId()].map(normalizeHost))].sort();
|
|
1458
|
+
const selectedSet = new Set(selected.map(normalizeHost));
|
|
1459
|
+
const unknown = [...selectedSet].filter((device) => !all.includes(device));
|
|
1460
|
+
if (unknown.length > 0)
|
|
1461
|
+
throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
|
|
1462
|
+
for (const device of all) {
|
|
1463
|
+
const action = selectedSet.has(device) ? 'resume' : 'pause';
|
|
1464
|
+
if (device === normalizeHost(machineId())) {
|
|
1465
|
+
setJobEnabled(name, action === 'resume');
|
|
1466
|
+
if (isDaemonRunning())
|
|
1467
|
+
signalDaemonReload();
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
const launch = getCliLaunch(['routines', action, name, '--host', device]);
|
|
1471
|
+
const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', env: process.env });
|
|
1472
|
+
if ((result.status ?? 1) !== 0)
|
|
1473
|
+
throw new Error(`Could not ${action} '${name}' on ${device}`);
|
|
1474
|
+
}
|
|
1475
|
+
console.log(chalk.green(selected.length === 0
|
|
1476
|
+
? `Routine '${name}' disabled on every registered device`
|
|
1477
|
+
: `Routine '${name}' enabled on: ${selected.join(', ')}`));
|
|
1478
|
+
};
|
|
1460
1479
|
if (options.clear) {
|
|
1461
|
-
|
|
1462
|
-
if (placementRequiresFiringPin(strategy)) {
|
|
1463
|
-
console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
|
|
1464
|
-
console.error(chalk.gray(`Keep a single-machine pin (e.g. --set ${machineId()}) or switch to --placement local.`));
|
|
1465
|
-
process.exit(1);
|
|
1466
|
-
}
|
|
1467
|
-
job.devices = undefined;
|
|
1468
|
-
writeJob(job);
|
|
1469
|
-
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
1470
|
-
if (isDaemonRunning())
|
|
1471
|
-
signalDaemonReload();
|
|
1480
|
+
await applyDevices([]);
|
|
1472
1481
|
return;
|
|
1473
1482
|
}
|
|
1474
1483
|
if (hasSet) {
|
|
1475
|
-
|
|
1476
|
-
job.devices = devices;
|
|
1477
|
-
// Re-validate so host/fleet/cloud can't be left with an empty set via --set ""
|
|
1478
|
-
const errs = validateJob(job);
|
|
1479
|
-
if (errs.length > 0) {
|
|
1480
|
-
console.error(chalk.red(errs.join('\n')));
|
|
1481
|
-
process.exit(1);
|
|
1482
|
-
}
|
|
1483
|
-
writeJob(job);
|
|
1484
|
-
console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
|
|
1485
|
-
if (isDaemonRunning())
|
|
1486
|
-
signalDaemonReload();
|
|
1484
|
+
await applyDevices(await parseAndValidateDevices(options.set));
|
|
1487
1485
|
return;
|
|
1488
1486
|
}
|
|
1489
1487
|
// Interactive picker
|
|
@@ -1496,34 +1494,18 @@ export function registerRoutinesCommands(program) {
|
|
|
1496
1494
|
console.log(chalk.yellow('No devices registered. Enroll with: agents devices sync'));
|
|
1497
1495
|
return;
|
|
1498
1496
|
}
|
|
1499
|
-
const currentSet = new Set((
|
|
1497
|
+
const currentSet = new Set(devicesWithRoutineEnabled(name).map(normalizeHost));
|
|
1500
1498
|
try {
|
|
1501
1499
|
const { checkbox } = await import('@inquirer/prompts');
|
|
1502
1500
|
const selected = await checkbox({
|
|
1503
|
-
message: `Devices
|
|
1501
|
+
message: `Devices where '${name}' is enabled (space to toggle, enter to confirm):`,
|
|
1504
1502
|
choices: registeredNames.map((d) => ({
|
|
1505
1503
|
value: d,
|
|
1506
1504
|
name: d,
|
|
1507
1505
|
checked: currentSet.has(d),
|
|
1508
1506
|
})),
|
|
1509
1507
|
});
|
|
1510
|
-
|
|
1511
|
-
const strategy = resolveHostStrategy(job);
|
|
1512
|
-
if (placementRequiresFiringPin(strategy)) {
|
|
1513
|
-
console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
job.devices = undefined;
|
|
1517
|
-
writeJob(job);
|
|
1518
|
-
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
1519
|
-
}
|
|
1520
|
-
else {
|
|
1521
|
-
job.devices = selected;
|
|
1522
|
-
writeJob(job);
|
|
1523
|
-
console.log(chalk.green(`Devices for '${name}' set to: ${selected.join(', ')}`));
|
|
1524
|
-
}
|
|
1525
|
-
if (isDaemonRunning())
|
|
1526
|
-
signalDaemonReload();
|
|
1508
|
+
await applyDevices(selected);
|
|
1527
1509
|
}
|
|
1528
1510
|
catch (err) {
|
|
1529
1511
|
if (isPromptCancelled(err)) {
|
|
@@ -281,8 +281,8 @@ export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: s
|
|
|
281
281
|
*/
|
|
282
282
|
export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
|
|
283
283
|
/** The intentionally small metadata contract emitted by `sessions --resolve`.
|
|
284
|
-
*
|
|
285
|
-
*
|
|
284
|
+
* It includes only launch identity needed to route/resume. Transcript paths,
|
|
285
|
+
* extracted plans, costs, and content stay on the machine that owns them. */
|
|
286
286
|
export declare function serializeResolvedSessionsJson(sessions: SessionMeta[]): string;
|
|
287
287
|
/**
|
|
288
288
|
* Whether the local machine's sessions belong in an `--active` view. Local is
|
|
@@ -636,12 +636,21 @@ export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: strin
|
|
|
636
636
|
export declare function metadataResolveForwardedArgs(selector: string, scope: Pick<SessionFilterOptions, 'agent' | 'project'>): string[];
|
|
637
637
|
/** Build the local-only peer argv for a distributed tool search. */
|
|
638
638
|
export declare function toolSearchForwardedArgs(argv: string[], hosts: string[]): string[];
|
|
639
|
-
/** Resolution
|
|
640
|
-
*
|
|
639
|
+
/** Resolution fails closed for prefixes/keywords when a peer did not answer.
|
|
640
|
+
* A full UUID is globally unique, so one exact hit is sufficient even when an
|
|
641
|
+
* unrelated registered device is offline. */
|
|
641
642
|
export declare function metadataResolveOutcome(localMatches: SessionMeta[], remote: {
|
|
642
643
|
sessions: SessionMeta[];
|
|
643
644
|
unreachable: string[];
|
|
644
645
|
}, selector: string): MetadataResolveOutcome;
|
|
646
|
+
/** Reusable local-first resolver for `agents run --resume` and `agents resume`.
|
|
647
|
+
* Full UUIDs hit the local SQLite index without any SSH fan-out. */
|
|
648
|
+
export declare function resolveSessionMetadataValue(selector: string, scope?: {
|
|
649
|
+
agent?: string;
|
|
650
|
+
project?: string;
|
|
651
|
+
local?: boolean;
|
|
652
|
+
hosts?: string[];
|
|
653
|
+
}, deps?: Pick<FleetResolveDeps, 'gatherRemoteList'>): Promise<MetadataResolveOutcome>;
|
|
645
654
|
/**
|
|
646
655
|
* Locate a full session id or short id prefix across the online fleet and render it from the machine
|
|
647
656
|
* that holds it. The local disk already missed; this fans `sessions <id> --json
|