@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/lib/overdue.js
CHANGED
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* `agents routines catchup` command that runs them on demand.
|
|
13
13
|
*/
|
|
14
14
|
import { Cron } from 'croner';
|
|
15
|
-
import * as os from 'os';
|
|
16
|
-
import { spawn } from 'child_process';
|
|
17
15
|
import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
|
|
16
|
+
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
18
17
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
19
18
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
20
19
|
const GRACE_MS = 60_000;
|
|
@@ -77,50 +76,18 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
77
76
|
return overdue;
|
|
78
77
|
}
|
|
79
78
|
/**
|
|
80
|
-
* Fire a
|
|
81
|
-
*
|
|
82
|
-
*
|
|
79
|
+
* Fire a branded desktop notification listing the overdue jobs. Routed through
|
|
80
|
+
* the MenubarHelper companion (notify-desktop.ts) so it carries the agents-cli
|
|
81
|
+
* mark; clicking opens the runs folder (~/.agents/.history/runs). Best-effort —
|
|
82
|
+
* a missing notifier or absent display is swallowed and never crashes the daemon.
|
|
83
83
|
*/
|
|
84
|
-
export function notifyDesktop(title, body) {
|
|
85
|
-
const platform = os.platform();
|
|
86
|
-
try {
|
|
87
|
-
if (platform === 'darwin') {
|
|
88
|
-
const safeTitle = title.replace(/"/g, '\\"');
|
|
89
|
-
const safeBody = body.replace(/"/g, '\\"');
|
|
90
|
-
const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
|
|
91
|
-
// A missing binary surfaces as an async 'error' event, NOT a synchronous
|
|
92
|
-
// throw the try/catch would catch. Without a listener Node re-throws it as
|
|
93
|
-
// an uncaught exception and takes the whole daemon down. Swallow it — the
|
|
94
|
-
// notification is best-effort.
|
|
95
|
-
child.on('error', () => { });
|
|
96
|
-
child.unref();
|
|
97
|
-
}
|
|
98
|
-
else if (platform === 'linux') {
|
|
99
|
-
const child = spawn('notify-send', [title, body], {
|
|
100
|
-
detached: true,
|
|
101
|
-
stdio: 'ignore',
|
|
102
|
-
});
|
|
103
|
-
// Headless Linux boxes have no `notify-send` (libnotify-bin); its ENOENT
|
|
104
|
-
// arrives as an async 'error' event, not a throw. Without this listener
|
|
105
|
-
// the daemon crashes on every overdue routine and systemd restart-loops it
|
|
106
|
-
// (which also tears down the browser IPC socket). Swallow — best-effort.
|
|
107
|
-
child.on('error', () => { });
|
|
108
|
-
child.unref();
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
// Notification is best-effort; nothing to do.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
/** Fire a native desktop notification listing the overdue jobs. Best-effort. */
|
|
116
84
|
export function notifyOverdue(jobs) {
|
|
117
85
|
if (jobs.length === 0)
|
|
118
86
|
return;
|
|
119
|
-
const title = jobs.length === 1
|
|
120
|
-
|
|
121
|
-
: `${jobs.length} routines overdue`;
|
|
87
|
+
const title = jobs.length === 1 ? 'Routine overdue' : `${jobs.length} routines overdue`;
|
|
88
|
+
const subtitle = jobs.length === 1 ? jobs[0].name : undefined;
|
|
122
89
|
const body = jobs.length === 1
|
|
123
90
|
? `Missed ${jobs[0].expectedAt.toLocaleString()}. Run: agents routines catchup`
|
|
124
91
|
: `${jobs.map((j) => j.name).join(', ')} — agents routines catchup`;
|
|
125
|
-
notifyDesktop(title, body);
|
|
92
|
+
notifyDesktop({ title, subtitle, body, action: 'routines:list' });
|
|
126
93
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine lifecycle desktop notifications (RUSH-2030).
|
|
3
|
+
*
|
|
4
|
+
* The daemon fires a branded notification when a scheduled routine starts and
|
|
5
|
+
* when it finishes (success or failure), routed through the MenubarHelper
|
|
6
|
+
* companion (notify-desktop.ts) so it carries the agents-cli mark.
|
|
7
|
+
*
|
|
8
|
+
* Anti-spam threshold (Acceptance Criteria #4 — "define a sensible threshold so
|
|
9
|
+
* users are not spammed"):
|
|
10
|
+
* - Agent / workflow routines: notify on BOTH start and finish. These are the
|
|
11
|
+
* runs whose output a user actually wants surfaced.
|
|
12
|
+
* - Command routines (deterministic housekeeping — version checks, `git pull`,
|
|
13
|
+
* notify shims that can fire every minute): notify only on FAILURE. A green
|
|
14
|
+
* housekeeping run is noise; a broken one is worth a ping. No start ping.
|
|
15
|
+
* - "Notable output" is folded into the single finish notification rather than
|
|
16
|
+
* sent as a third message: on failure the error reason, on success the first
|
|
17
|
+
* line of the run's report (the user-facing result) when the routine produced
|
|
18
|
+
* one. One start + one finish per run — never a stream.
|
|
19
|
+
*
|
|
20
|
+
* The pure builders (`routineStartNotification` / `routineFinishNotification`)
|
|
21
|
+
* return `null` when the threshold says "don't notify", and are unit-tested; the
|
|
22
|
+
* `notifyRoutine*` wrappers do the filesystem read + dispatch for the daemon.
|
|
23
|
+
*/
|
|
24
|
+
import type { JobConfig, RunMeta } from './routines.js';
|
|
25
|
+
import { type DesktopNotification } from './menubar/notify-desktop.js';
|
|
26
|
+
type RoutineKind = 'agent' | 'workflow' | 'command';
|
|
27
|
+
/** Which flavor of routine a config/meta describes — drives the notify threshold. */
|
|
28
|
+
export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
|
|
29
|
+
/** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
|
|
30
|
+
export declare function formatDuration(ms: number | undefined): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* First non-empty line of a run report, trimmed to a notification-sized snippet.
|
|
33
|
+
* This is the "notable output" surfaced on a successful finish — the routine's
|
|
34
|
+
* own user-facing result. Returns null for an empty/whitespace report so the
|
|
35
|
+
* caller falls back to a plain "Completed" body.
|
|
36
|
+
*/
|
|
37
|
+
export declare function notableSnippet(report: string | null | undefined, maxLen?: number): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Notification for a routine START, or null when the threshold suppresses it
|
|
40
|
+
* (command-mode housekeeping). Clicking opens the runs folder
|
|
41
|
+
* (~/.agents/.history/runs).
|
|
42
|
+
*/
|
|
43
|
+
export declare function routineStartNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>): DesktopNotification | null;
|
|
44
|
+
/**
|
|
45
|
+
* Notification for a routine that failed to even START — `executeJobDetached`
|
|
46
|
+
* threw before the child was spawned, so no run record and no finish will ever
|
|
47
|
+
* exist. The daemon fires the START notification unconditionally, so this closes
|
|
48
|
+
* the "exactly one start + one finish" invariant: the orphaned start gets its
|
|
49
|
+
* matching failure banner (RUSH-2030). Unlike a green finish this is never
|
|
50
|
+
* suppressed — a broken start is worth a ping for every routine kind, including
|
|
51
|
+
* command housekeeping. Clicking opens the runs folder (~/.agents/.history/runs)
|
|
52
|
+
* since there is no run report to open.
|
|
53
|
+
*/
|
|
54
|
+
export declare function routineStartFailedNotification(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>, error: string): DesktopNotification;
|
|
55
|
+
/**
|
|
56
|
+
* Notification for a routine FINISH, or null when the threshold suppresses it
|
|
57
|
+
* (a successful command-mode housekeeping run). Success carries the report's
|
|
58
|
+
* first line when present (the notable output); failure carries the reason.
|
|
59
|
+
* Clicking opens the run report/log when one is available, else the runs folder
|
|
60
|
+
* (~/.agents/.history/runs).
|
|
61
|
+
*/
|
|
62
|
+
export declare function routineFinishNotification(meta: Pick<RunMeta, 'jobName' | 'status' | 'exitCode' | 'errorMessage' | 'duration' | 'agent' | 'workflow' | 'command'>, opts?: {
|
|
63
|
+
report?: string | null;
|
|
64
|
+
artifactPath?: string | null;
|
|
65
|
+
}): DesktopNotification | null;
|
|
66
|
+
/** Daemon glue: fire the START notification for a triggered routine. Best-effort. */
|
|
67
|
+
export declare function notifyRoutineStart(config: JobConfig): void;
|
|
68
|
+
/**
|
|
69
|
+
* Daemon glue: fire the "failed to start" notification when a routine trigger
|
|
70
|
+
* threw before spawning a child. Pairs with the unconditional START ping so a
|
|
71
|
+
* pre-spawn failure never leaves an orphaned "Routine started". Best-effort.
|
|
72
|
+
*/
|
|
73
|
+
export declare function notifyRoutineStartFailed(config: JobConfig, error: string): void;
|
|
74
|
+
/** Daemon glue: fire the FINISH notification for a completed run. Best-effort. */
|
|
75
|
+
export declare function notifyRoutineFinish(meta: RunMeta): void;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine lifecycle desktop notifications (RUSH-2030).
|
|
3
|
+
*
|
|
4
|
+
* The daemon fires a branded notification when a scheduled routine starts and
|
|
5
|
+
* when it finishes (success or failure), routed through the MenubarHelper
|
|
6
|
+
* companion (notify-desktop.ts) so it carries the agents-cli mark.
|
|
7
|
+
*
|
|
8
|
+
* Anti-spam threshold (Acceptance Criteria #4 — "define a sensible threshold so
|
|
9
|
+
* users are not spammed"):
|
|
10
|
+
* - Agent / workflow routines: notify on BOTH start and finish. These are the
|
|
11
|
+
* runs whose output a user actually wants surfaced.
|
|
12
|
+
* - Command routines (deterministic housekeeping — version checks, `git pull`,
|
|
13
|
+
* notify shims that can fire every minute): notify only on FAILURE. A green
|
|
14
|
+
* housekeeping run is noise; a broken one is worth a ping. No start ping.
|
|
15
|
+
* - "Notable output" is folded into the single finish notification rather than
|
|
16
|
+
* sent as a third message: on failure the error reason, on success the first
|
|
17
|
+
* line of the run's report (the user-facing result) when the routine produced
|
|
18
|
+
* one. One start + one finish per run — never a stream.
|
|
19
|
+
*
|
|
20
|
+
* The pure builders (`routineStartNotification` / `routineFinishNotification`)
|
|
21
|
+
* return `null` when the threshold says "don't notify", and are unit-tested; the
|
|
22
|
+
* `notifyRoutine*` wrappers do the filesystem read + dispatch for the daemon.
|
|
23
|
+
*/
|
|
24
|
+
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
26
|
+
import { getRunDir } from './routines.js';
|
|
27
|
+
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
28
|
+
/** Which flavor of routine a config/meta describes — drives the notify threshold. */
|
|
29
|
+
export function routineKind(r) {
|
|
30
|
+
if (r.command)
|
|
31
|
+
return 'command';
|
|
32
|
+
if (r.workflow)
|
|
33
|
+
return 'workflow';
|
|
34
|
+
return 'agent';
|
|
35
|
+
}
|
|
36
|
+
/** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
|
|
37
|
+
function routineLabel(r) {
|
|
38
|
+
if (r.command)
|
|
39
|
+
return 'command';
|
|
40
|
+
if (r.workflow)
|
|
41
|
+
return `workflow ${r.workflow}`;
|
|
42
|
+
return `agent ${r.agent ?? 'unknown'}`;
|
|
43
|
+
}
|
|
44
|
+
/** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
|
|
45
|
+
export function formatDuration(ms) {
|
|
46
|
+
if (ms === undefined || !Number.isFinite(ms) || ms < 0)
|
|
47
|
+
return null;
|
|
48
|
+
const totalSec = Math.round(ms / 1000);
|
|
49
|
+
if (totalSec < 60)
|
|
50
|
+
return `${totalSec}s`;
|
|
51
|
+
const min = Math.floor(totalSec / 60);
|
|
52
|
+
const sec = totalSec % 60;
|
|
53
|
+
if (min < 60)
|
|
54
|
+
return sec ? `${min}m ${sec}s` : `${min}m`;
|
|
55
|
+
const hr = Math.floor(min / 60);
|
|
56
|
+
const remMin = min % 60;
|
|
57
|
+
return remMin ? `${hr}h ${remMin}m` : `${hr}h`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* First non-empty line of a run report, trimmed to a notification-sized snippet.
|
|
61
|
+
* This is the "notable output" surfaced on a successful finish — the routine's
|
|
62
|
+
* own user-facing result. Returns null for an empty/whitespace report so the
|
|
63
|
+
* caller falls back to a plain "Completed" body.
|
|
64
|
+
*/
|
|
65
|
+
export function notableSnippet(report, maxLen = 140) {
|
|
66
|
+
if (!report)
|
|
67
|
+
return null;
|
|
68
|
+
const firstLine = report
|
|
69
|
+
.split('\n')
|
|
70
|
+
.map((l) => l.trim())
|
|
71
|
+
.find((l) => l.length > 0);
|
|
72
|
+
if (!firstLine)
|
|
73
|
+
return null;
|
|
74
|
+
return firstLine.length > maxLen ? `${firstLine.slice(0, maxLen - 1).trimEnd()}…` : firstLine;
|
|
75
|
+
}
|
|
76
|
+
/** Encode a click action that opens a file (report/log) in the default app. */
|
|
77
|
+
function openAction(filePath) {
|
|
78
|
+
return filePath ? `open:${filePath}` : undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Notification for a routine START, or null when the threshold suppresses it
|
|
82
|
+
* (command-mode housekeeping). Clicking opens the runs folder
|
|
83
|
+
* (~/.agents/.history/runs).
|
|
84
|
+
*/
|
|
85
|
+
export function routineStartNotification(config) {
|
|
86
|
+
if (routineKind(config) === 'command')
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
title: 'Routine started',
|
|
90
|
+
subtitle: config.name,
|
|
91
|
+
body: `Running ${routineLabel(config)}`,
|
|
92
|
+
action: 'routines:list',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Notification for a routine that failed to even START — `executeJobDetached`
|
|
97
|
+
* threw before the child was spawned, so no run record and no finish will ever
|
|
98
|
+
* exist. The daemon fires the START notification unconditionally, so this closes
|
|
99
|
+
* the "exactly one start + one finish" invariant: the orphaned start gets its
|
|
100
|
+
* matching failure banner (RUSH-2030). Unlike a green finish this is never
|
|
101
|
+
* suppressed — a broken start is worth a ping for every routine kind, including
|
|
102
|
+
* command housekeeping. Clicking opens the runs folder (~/.agents/.history/runs)
|
|
103
|
+
* since there is no run report to open.
|
|
104
|
+
*/
|
|
105
|
+
export function routineStartFailedNotification(config, error) {
|
|
106
|
+
return {
|
|
107
|
+
title: 'Routine failed',
|
|
108
|
+
subtitle: config.name,
|
|
109
|
+
body: `Failed to start: ${error}`,
|
|
110
|
+
action: 'routines:list',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Notification for a routine FINISH, or null when the threshold suppresses it
|
|
115
|
+
* (a successful command-mode housekeeping run). Success carries the report's
|
|
116
|
+
* first line when present (the notable output); failure carries the reason.
|
|
117
|
+
* Clicking opens the run report/log when one is available, else the runs folder
|
|
118
|
+
* (~/.agents/.history/runs).
|
|
119
|
+
*/
|
|
120
|
+
export function routineFinishNotification(meta, opts = {}) {
|
|
121
|
+
const kind = routineKind(meta);
|
|
122
|
+
const ok = meta.status === 'completed';
|
|
123
|
+
if (kind === 'command' && ok)
|
|
124
|
+
return null; // green housekeeping is noise
|
|
125
|
+
const action = openAction(opts.artifactPath) ?? 'routines:list';
|
|
126
|
+
if (ok) {
|
|
127
|
+
const snippet = notableSnippet(opts.report);
|
|
128
|
+
const dur = formatDuration(meta.duration);
|
|
129
|
+
return {
|
|
130
|
+
title: 'Routine finished',
|
|
131
|
+
subtitle: meta.jobName,
|
|
132
|
+
body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
|
|
133
|
+
action,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// failed | timeout
|
|
137
|
+
const reason = meta.status === 'timeout'
|
|
138
|
+
? 'Timed out'
|
|
139
|
+
: meta.errorMessage
|
|
140
|
+
? meta.errorMessage
|
|
141
|
+
: `Exited with code ${meta.exitCode ?? '?'}`;
|
|
142
|
+
return {
|
|
143
|
+
title: 'Routine failed',
|
|
144
|
+
subtitle: meta.jobName,
|
|
145
|
+
body: reason,
|
|
146
|
+
action,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Read a finished run's report text + the best artifact to open on click. */
|
|
150
|
+
function loadRunArtifacts(meta) {
|
|
151
|
+
try {
|
|
152
|
+
const runDir = getRunDir(meta.jobName, meta.runId);
|
|
153
|
+
const reportPath = path.join(runDir, 'report.md');
|
|
154
|
+
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
155
|
+
let report = null;
|
|
156
|
+
let artifactPath = null;
|
|
157
|
+
if (fs.existsSync(reportPath)) {
|
|
158
|
+
report = fs.readFileSync(reportPath, 'utf-8');
|
|
159
|
+
artifactPath = reportPath;
|
|
160
|
+
}
|
|
161
|
+
else if (fs.existsSync(stdoutPath)) {
|
|
162
|
+
artifactPath = stdoutPath;
|
|
163
|
+
}
|
|
164
|
+
return { report, artifactPath };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { report: null, artifactPath: null };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Daemon glue: fire the START notification for a triggered routine. Best-effort. */
|
|
171
|
+
export function notifyRoutineStart(config) {
|
|
172
|
+
const n = routineStartNotification(config);
|
|
173
|
+
if (n)
|
|
174
|
+
notifyDesktop(n);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Daemon glue: fire the "failed to start" notification when a routine trigger
|
|
178
|
+
* threw before spawning a child. Pairs with the unconditional START ping so a
|
|
179
|
+
* pre-spawn failure never leaves an orphaned "Routine started". Best-effort.
|
|
180
|
+
*/
|
|
181
|
+
export function notifyRoutineStartFailed(config, error) {
|
|
182
|
+
notifyDesktop(routineStartFailedNotification(config, error));
|
|
183
|
+
}
|
|
184
|
+
/** Daemon glue: fire the FINISH notification for a completed run. Best-effort. */
|
|
185
|
+
export function notifyRoutineFinish(meta) {
|
|
186
|
+
const { report, artifactPath } = loadRunArtifacts(meta);
|
|
187
|
+
const n = routineFinishNotification(meta, { report, artifactPath });
|
|
188
|
+
if (n)
|
|
189
|
+
notifyDesktop(n);
|
|
190
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime placement resolution for routines: local / host / fleet / cloud.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler only decides *whether* this machine may fire the job
|
|
5
|
+
* (`devices` + `jobRunsOnThisDevice`). This module decides *where the job
|
|
6
|
+
* body runs* once it has been fired, using `hostStrategy`.
|
|
7
|
+
*
|
|
8
|
+
* Fleet semantics (RUSH-2035 / RUSH-1980): pick exactly one online device per
|
|
9
|
+
* fire. Cross-device double-fire is prevented by requiring a `devices` firing
|
|
10
|
+
* pin for fleet/host/cloud strategies (applied at add/sync time).
|
|
11
|
+
*/
|
|
12
|
+
import type { JobConfig } from './routines.js';
|
|
13
|
+
export type PlacementTarget = {
|
|
14
|
+
mode: 'local';
|
|
15
|
+
} | {
|
|
16
|
+
mode: 'host';
|
|
17
|
+
host: string;
|
|
18
|
+
} | {
|
|
19
|
+
mode: 'cloud';
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Pick one online fleet device for `hostStrategy: fleet`.
|
|
23
|
+
*
|
|
24
|
+
* Preference order:
|
|
25
|
+
* 1. This machine, if online and eligible (avoids needless SSH)
|
|
26
|
+
* 2. First eligible online device by name (stable / deterministic)
|
|
27
|
+
*
|
|
28
|
+
* `config.devices` is the *firing* allowlist only (which daemon may fire the
|
|
29
|
+
* job). It is intentionally NOT used as an execution pool filter — otherwise
|
|
30
|
+
* the double-fire pin (`devices: [self]`) would collapse fleet placement to
|
|
31
|
+
* always-local. Control / offline / no-address devices are never chosen.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pickFleetDevice(_config?: Pick<JobConfig, 'devices'>): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve where a fired job's body should execute.
|
|
36
|
+
* Throws a human-readable Error when placement cannot be satisfied.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolvePlacementTarget(config: JobConfig): PlacementTarget;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime placement resolution for routines: local / host / fleet / cloud.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler only decides *whether* this machine may fire the job
|
|
5
|
+
* (`devices` + `jobRunsOnThisDevice`). This module decides *where the job
|
|
6
|
+
* body runs* once it has been fired, using `hostStrategy`.
|
|
7
|
+
*
|
|
8
|
+
* Fleet semantics (RUSH-2035 / RUSH-1980): pick exactly one online device per
|
|
9
|
+
* fire. Cross-device double-fire is prevented by requiring a `devices` firing
|
|
10
|
+
* pin for fleet/host/cloud strategies (applied at add/sync time).
|
|
11
|
+
*/
|
|
12
|
+
import { resolveHostStrategy } from './routines.js';
|
|
13
|
+
import { machineId, normalizeHost } from './machine-id.js';
|
|
14
|
+
import { loadDevicesSync } from './devices/registry.js';
|
|
15
|
+
import { planFleetTargets } from './devices/fleet.js';
|
|
16
|
+
/**
|
|
17
|
+
* Pick one online fleet device for `hostStrategy: fleet`.
|
|
18
|
+
*
|
|
19
|
+
* Preference order:
|
|
20
|
+
* 1. This machine, if online and eligible (avoids needless SSH)
|
|
21
|
+
* 2. First eligible online device by name (stable / deterministic)
|
|
22
|
+
*
|
|
23
|
+
* `config.devices` is the *firing* allowlist only (which daemon may fire the
|
|
24
|
+
* job). It is intentionally NOT used as an execution pool filter — otherwise
|
|
25
|
+
* the double-fire pin (`devices: [self]`) would collapse fleet placement to
|
|
26
|
+
* always-local. Control / offline / no-address devices are never chosen.
|
|
27
|
+
*/
|
|
28
|
+
export function pickFleetDevice(_config) {
|
|
29
|
+
let reg;
|
|
30
|
+
try {
|
|
31
|
+
reg = loadDevicesSync();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const planned = planFleetTargets(reg);
|
|
37
|
+
const candidates = planned.filter((t) => !t.skip).map((t) => t.device.name);
|
|
38
|
+
if (candidates.length === 0) {
|
|
39
|
+
// No registry / nothing online: fall back to self so a single-box fleet
|
|
40
|
+
// without a registry entry still runs locally.
|
|
41
|
+
return machineId();
|
|
42
|
+
}
|
|
43
|
+
const self = machineId();
|
|
44
|
+
const selfMatch = candidates.find((n) => normalizeHost(n) === self);
|
|
45
|
+
if (selfMatch)
|
|
46
|
+
return selfMatch;
|
|
47
|
+
return [...candidates].sort((a, b) => a.localeCompare(b))[0] ?? null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve where a fired job's body should execute.
|
|
51
|
+
* Throws a human-readable Error when placement cannot be satisfied.
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePlacementTarget(config) {
|
|
54
|
+
const strategy = resolveHostStrategy(config);
|
|
55
|
+
switch (strategy) {
|
|
56
|
+
case 'local':
|
|
57
|
+
return { mode: 'local' };
|
|
58
|
+
case 'host': {
|
|
59
|
+
if (!config.host || config.host.trim() === '') {
|
|
60
|
+
throw new Error(`Routine '${config.name}' has hostStrategy: host but no host: — set host: or --run-on`);
|
|
61
|
+
}
|
|
62
|
+
// host: pointing at this machine is local execution (no SSH loop).
|
|
63
|
+
if (normalizeHost(config.host) === machineId())
|
|
64
|
+
return { mode: 'local' };
|
|
65
|
+
return { mode: 'host', host: config.host };
|
|
66
|
+
}
|
|
67
|
+
case 'fleet': {
|
|
68
|
+
const picked = pickFleetDevice(config);
|
|
69
|
+
if (!picked) {
|
|
70
|
+
throw new Error(`Routine '${config.name}' hostStrategy: fleet — no eligible online device to place the run`);
|
|
71
|
+
}
|
|
72
|
+
if (normalizeHost(picked) === machineId())
|
|
73
|
+
return { mode: 'local' };
|
|
74
|
+
return { mode: 'host', host: picked };
|
|
75
|
+
}
|
|
76
|
+
case 'cloud':
|
|
77
|
+
return { mode: 'cloud' };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level routine opt-in, source tracking, and user-layer sync.
|
|
3
|
+
*
|
|
4
|
+
* Project YAML under `<project>/.agents/routines/*.yml` is inspection-only by
|
|
5
|
+
* default (a cloned public repo must never auto-fire agent prompts). After an
|
|
6
|
+
* explicit opt-in, routines are materialised into `~/.agents/routines/` with
|
|
7
|
+
* `source:` provenance so the daemon — which only loads user + system layers —
|
|
8
|
+
* can fire them. `syncProjectRoutines` refreshes the user-layer copies when
|
|
9
|
+
* project YAML changes (also invoked on daemon SIGHUP).
|
|
10
|
+
*/
|
|
11
|
+
import { type JobSource } from './routines.js';
|
|
12
|
+
/** Expand `~/…` and resolve to an absolute path. */
|
|
13
|
+
export declare function expandProjectPath(p: string): string;
|
|
14
|
+
/** Store paths home-relative when under $HOME so they travel across machines. */
|
|
15
|
+
export declare function displayProjectPath(abs: string): string;
|
|
16
|
+
/** Project roots currently opted into daemon firing (absolute paths). */
|
|
17
|
+
export declare function listEnabledProjectRoots(): string[];
|
|
18
|
+
/** True when this project root is on the opt-in allowlist. */
|
|
19
|
+
export declare function isProjectRoutinesEnabled(projectRoot: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* True when the project's own `agents.yaml` opts into project routines:
|
|
22
|
+
* `routines: { enable: true }`. This is an additional source of opt-in that
|
|
23
|
+
* still requires the project to be present on disk; it does not auto-enable
|
|
24
|
+
* every clone — the project must declare it, and `sync` / `enable-project`
|
|
25
|
+
* still materialises consent into the user layer.
|
|
26
|
+
*/
|
|
27
|
+
export declare function projectAgentsYamlEnablesRoutines(projectRoot: string): boolean;
|
|
28
|
+
/** Resolve the project root (parent of `.agents/`) from a cwd, or null. */
|
|
29
|
+
export declare function resolveProjectRoot(cwd?: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Opt a project root into daemon firing. Returns true when newly added,
|
|
32
|
+
* false when it was already enabled.
|
|
33
|
+
*/
|
|
34
|
+
export declare function enableProjectRoutines(projectRoot: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Remove a project root from the opt-in allowlist. Optionally deletes the
|
|
37
|
+
* user-layer copies that were materialised from it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function disableProjectRoutines(projectRoot: string, opts?: {
|
|
40
|
+
removeSynced?: boolean;
|
|
41
|
+
}): {
|
|
42
|
+
removed: boolean;
|
|
43
|
+
deletedJobs: string[];
|
|
44
|
+
};
|
|
45
|
+
/** Git provenance for a project root (best-effort; never throws). */
|
|
46
|
+
export declare function readProjectGitSource(projectRoot: string): Pick<JobSource, 'repo' | 'branch' | 'commit'>;
|
|
47
|
+
/** List project routine YAML files (name + absolute path). */
|
|
48
|
+
export declare function listProjectRoutineFiles(projectRoot: string): Array<{
|
|
49
|
+
name: string;
|
|
50
|
+
path: string;
|
|
51
|
+
}>;
|
|
52
|
+
export interface SyncProjectResult {
|
|
53
|
+
projectRoot: string;
|
|
54
|
+
synced: string[];
|
|
55
|
+
skipped: Array<{
|
|
56
|
+
name: string;
|
|
57
|
+
reason: string;
|
|
58
|
+
}>;
|
|
59
|
+
removed: string[];
|
|
60
|
+
errors: Array<{
|
|
61
|
+
name: string;
|
|
62
|
+
error: string;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Materialise one project's routines into the user layer.
|
|
67
|
+
* - Overwrites user copies that already carry matching `source.projectPath`
|
|
68
|
+
* - Never clobbers a hand-authored user routine (no source / different source)
|
|
69
|
+
* - Removes user copies from this project whose YAML disappeared
|
|
70
|
+
* - Auto-pins `devices` for host/fleet/cloud placement when unset
|
|
71
|
+
*/
|
|
72
|
+
export declare function syncProjectRoutines(projectRoot: string): SyncProjectResult;
|
|
73
|
+
export interface SyncAllResult {
|
|
74
|
+
projects: SyncProjectResult[];
|
|
75
|
+
/** Project roots that were listed but missing on disk. */
|
|
76
|
+
missing: string[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sync every project root on the user allowlist (`meta.routines.projects`),
|
|
80
|
+
* plus any explicit `extraRoots`. Project `agents.yaml` `routines.enable`
|
|
81
|
+
* alone never opts a path in — enable-project is required.
|
|
82
|
+
*/
|
|
83
|
+
export declare function syncAllProjectRoutines(opts?: {
|
|
84
|
+
extraRoots?: string[];
|
|
85
|
+
}): SyncAllResult;
|
|
86
|
+
/**
|
|
87
|
+
* Discover project routines at cwd for the setup UX. Returns null when no
|
|
88
|
+
* project `.agents/routines` dir exists.
|
|
89
|
+
*/
|
|
90
|
+
export declare function discoverProjectRoutinesAt(cwd?: string): {
|
|
91
|
+
projectRoot: string;
|
|
92
|
+
files: Array<{
|
|
93
|
+
name: string;
|
|
94
|
+
path: string;
|
|
95
|
+
}>;
|
|
96
|
+
enabled: boolean;
|
|
97
|
+
} | null;
|