@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -0,0 +1,349 @@
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 * as fs from 'fs';
12
+ import * as os from 'os';
13
+ import * as path from 'path';
14
+ import * as yaml from 'yaml';
15
+ import { execFileSync } from 'child_process';
16
+ import { getProjectAgentsDir, getProjectRoutinesDir, readMeta, updateMeta, ensureAgentsDir, } from './state.js';
17
+ import { readJob, writeJob, deleteJob, listJobs, validateJob, resolveHostStrategy, placementRequiresFiringPin, } from './routines.js';
18
+ import { parseOwnerRepoFromRemote } from './registry.js';
19
+ import { machineId } from './machine-id.js';
20
+ import { isSafeSegmentName } from './paths.js';
21
+ /** Expand `~/…` and resolve to an absolute path. */
22
+ export function expandProjectPath(p) {
23
+ const trimmed = p.trim();
24
+ if (trimmed.startsWith('~/') || trimmed === '~') {
25
+ return path.resolve(trimmed.replace(/^~(?=$|[/\\])/, os.homedir()));
26
+ }
27
+ return path.resolve(trimmed);
28
+ }
29
+ /** Store paths home-relative when under $HOME so they travel across machines. */
30
+ export function displayProjectPath(abs) {
31
+ const home = os.homedir();
32
+ const resolved = path.resolve(abs);
33
+ if (resolved === home)
34
+ return '~';
35
+ if (resolved.startsWith(home + path.sep)) {
36
+ return '~' + resolved.slice(home.length);
37
+ }
38
+ return resolved;
39
+ }
40
+ /** Project roots currently opted into daemon firing (absolute paths). */
41
+ export function listEnabledProjectRoots() {
42
+ const meta = readMeta();
43
+ const raw = meta.routines?.projects ?? [];
44
+ const out = [];
45
+ const seen = new Set();
46
+ for (const p of raw) {
47
+ if (typeof p !== 'string' || p.trim() === '')
48
+ continue;
49
+ const abs = expandProjectPath(p);
50
+ if (seen.has(abs))
51
+ continue;
52
+ seen.add(abs);
53
+ out.push(abs);
54
+ }
55
+ return out;
56
+ }
57
+ /** True when this project root is on the opt-in allowlist. */
58
+ export function isProjectRoutinesEnabled(projectRoot) {
59
+ const abs = expandProjectPath(projectRoot);
60
+ return listEnabledProjectRoots().some((p) => p === abs);
61
+ }
62
+ /**
63
+ * True when the project's own `agents.yaml` opts into project routines:
64
+ * `routines: { enable: true }`. This is an additional source of opt-in that
65
+ * still requires the project to be present on disk; it does not auto-enable
66
+ * every clone — the project must declare it, and `sync` / `enable-project`
67
+ * still materialises consent into the user layer.
68
+ */
69
+ export function projectAgentsYamlEnablesRoutines(projectRoot) {
70
+ const agentsYaml = path.join(expandProjectPath(projectRoot), 'agents.yaml');
71
+ if (!fs.existsSync(agentsYaml))
72
+ return false;
73
+ try {
74
+ const parsed = yaml.parse(fs.readFileSync(agentsYaml, 'utf-8'));
75
+ if (!parsed || typeof parsed !== 'object')
76
+ return false;
77
+ const routines = parsed.routines;
78
+ if (!routines || typeof routines !== 'object')
79
+ return false;
80
+ return routines.enable === true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ /** Resolve the project root (parent of `.agents/`) from a cwd, or null. */
87
+ export function resolveProjectRoot(cwd = process.cwd()) {
88
+ const agentsDir = getProjectAgentsDir(cwd);
89
+ if (!agentsDir)
90
+ return null;
91
+ return path.dirname(agentsDir);
92
+ }
93
+ /**
94
+ * Opt a project root into daemon firing. Returns true when newly added,
95
+ * false when it was already enabled.
96
+ */
97
+ export function enableProjectRoutines(projectRoot) {
98
+ const abs = expandProjectPath(projectRoot);
99
+ const stored = displayProjectPath(abs);
100
+ let added = false;
101
+ updateMeta((meta) => {
102
+ const projects = [...(meta.routines?.projects ?? [])];
103
+ const already = projects.some((p) => expandProjectPath(p) === abs);
104
+ if (already)
105
+ return meta;
106
+ projects.push(stored);
107
+ added = true;
108
+ return {
109
+ ...meta,
110
+ routines: {
111
+ ...(meta.routines ?? {}),
112
+ projects,
113
+ },
114
+ };
115
+ });
116
+ return added;
117
+ }
118
+ /**
119
+ * Remove a project root from the opt-in allowlist. Optionally deletes the
120
+ * user-layer copies that were materialised from it.
121
+ */
122
+ export function disableProjectRoutines(projectRoot, opts = {}) {
123
+ const abs = expandProjectPath(projectRoot);
124
+ let removed = false;
125
+ updateMeta((meta) => {
126
+ const projects = meta.routines?.projects ?? [];
127
+ const next = projects.filter((p) => expandProjectPath(p) !== abs);
128
+ if (next.length === projects.length)
129
+ return meta;
130
+ removed = true;
131
+ return {
132
+ ...meta,
133
+ routines: {
134
+ ...(meta.routines ?? {}),
135
+ projects: next,
136
+ },
137
+ };
138
+ });
139
+ const deletedJobs = [];
140
+ if (opts.removeSynced) {
141
+ for (const job of listJobs()) {
142
+ if (job.source?.kind === 'project' && expandProjectPath(job.source.projectPath) === abs) {
143
+ if (deleteJob(job.name))
144
+ deletedJobs.push(job.name);
145
+ }
146
+ }
147
+ }
148
+ return { removed, deletedJobs };
149
+ }
150
+ /** Git provenance for a project root (best-effort; never throws). */
151
+ export function readProjectGitSource(projectRoot) {
152
+ const abs = expandProjectPath(projectRoot);
153
+ const out = {};
154
+ try {
155
+ const remote = execFileSync('git', ['-C', abs, 'remote', 'get-url', 'origin'], {
156
+ encoding: 'utf-8',
157
+ stdio: ['ignore', 'pipe', 'ignore'],
158
+ timeout: 5_000,
159
+ }).trim();
160
+ const repo = parseOwnerRepoFromRemote(remote);
161
+ if (repo)
162
+ out.repo = repo;
163
+ }
164
+ catch { /* no origin */ }
165
+ try {
166
+ out.branch = execFileSync('git', ['-C', abs, 'rev-parse', '--abbrev-ref', 'HEAD'], {
167
+ encoding: 'utf-8',
168
+ stdio: ['ignore', 'pipe', 'ignore'],
169
+ timeout: 5_000,
170
+ }).trim();
171
+ if (out.branch === 'HEAD')
172
+ delete out.branch; // detached
173
+ }
174
+ catch { /* not a git repo */ }
175
+ try {
176
+ out.commit = execFileSync('git', ['-C', abs, 'rev-parse', '--short', 'HEAD'], {
177
+ encoding: 'utf-8',
178
+ stdio: ['ignore', 'pipe', 'ignore'],
179
+ timeout: 5_000,
180
+ }).trim();
181
+ }
182
+ catch { /* ignore */ }
183
+ return out;
184
+ }
185
+ /** List project routine YAML files (name + absolute path). */
186
+ export function listProjectRoutineFiles(projectRoot) {
187
+ const routinesDir = getProjectRoutinesDir(expandProjectPath(projectRoot));
188
+ if (!routinesDir || !fs.existsSync(routinesDir))
189
+ return [];
190
+ return fs.readdirSync(routinesDir)
191
+ .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
192
+ .map((f) => ({
193
+ name: f.replace(/\.ya?ml$/, ''),
194
+ path: path.join(routinesDir, f),
195
+ }))
196
+ .filter((e) => isSafeSegmentName(e.name));
197
+ }
198
+ function readProjectJobFile(filePath) {
199
+ try {
200
+ const content = fs.readFileSync(filePath, 'utf-8');
201
+ const parsed = yaml.parse(content);
202
+ if (!parsed || typeof parsed !== 'object')
203
+ return null;
204
+ if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
205
+ return null;
206
+ return {
207
+ mode: 'auto',
208
+ effort: 'auto',
209
+ timeout: '10m',
210
+ enabled: true,
211
+ ...parsed,
212
+ name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
213
+ };
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ /**
220
+ * Materialise one project's routines into the user layer.
221
+ * - Overwrites user copies that already carry matching `source.projectPath`
222
+ * - Never clobbers a hand-authored user routine (no source / different source)
223
+ * - Removes user copies from this project whose YAML disappeared
224
+ * - Auto-pins `devices` for host/fleet/cloud placement when unset
225
+ */
226
+ export function syncProjectRoutines(projectRoot) {
227
+ ensureAgentsDir();
228
+ const abs = expandProjectPath(projectRoot);
229
+ const git = readProjectGitSource(abs);
230
+ const source = {
231
+ kind: 'project',
232
+ projectPath: abs,
233
+ ...(git.repo ? { repo: git.repo } : {}),
234
+ ...(git.branch ? { branch: git.branch } : {}),
235
+ ...(git.commit ? { commit: git.commit } : {}),
236
+ };
237
+ const result = {
238
+ projectRoot: abs,
239
+ synced: [],
240
+ skipped: [],
241
+ removed: [],
242
+ errors: [],
243
+ };
244
+ const files = listProjectRoutineFiles(abs);
245
+ const seenNames = new Set();
246
+ for (const file of files) {
247
+ seenNames.add(file.name);
248
+ const job = readProjectJobFile(file.path);
249
+ if (!job) {
250
+ result.errors.push({ name: file.name, error: 'unreadable or invalid YAML' });
251
+ continue;
252
+ }
253
+ const existing = readJob(file.name);
254
+ if (existing) {
255
+ const existingSource = existing.source;
256
+ const fromThisProject = existingSource?.kind === 'project'
257
+ && expandProjectPath(existingSource.projectPath) === abs;
258
+ if (!fromThisProject) {
259
+ result.skipped.push({
260
+ name: file.name,
261
+ reason: existingSource
262
+ ? `user-layer routine already exists from another source (${existingSource.projectPath})`
263
+ : 'user-layer routine already exists (hand-authored); not overwriting without source match',
264
+ });
265
+ continue;
266
+ }
267
+ // Preserve user-layer devices pin when project YAML omits it (same overlay
268
+ // semantics as listJobs project discovery).
269
+ if (job.devices === undefined && existing.devices && existing.devices.length > 0) {
270
+ job.devices = existing.devices;
271
+ }
272
+ }
273
+ // Placement that leaves the firing machine must pin devices to avoid
274
+ // every fleet daemon dispatching once.
275
+ const strategy = resolveHostStrategy(job);
276
+ if (placementRequiresFiringPin(strategy) && (!job.devices || job.devices.length === 0)) {
277
+ job.devices = [machineId()];
278
+ }
279
+ // Surface repo for list/display when project has a GitHub origin.
280
+ if (git.repo && !job.repo)
281
+ job.repo = git.repo;
282
+ job.source = source;
283
+ job.name = file.name;
284
+ const errors = validateJob(job);
285
+ if (errors.length > 0) {
286
+ result.errors.push({ name: file.name, error: errors.join('; ') });
287
+ continue;
288
+ }
289
+ try {
290
+ writeJob(job);
291
+ result.synced.push(file.name);
292
+ }
293
+ catch (err) {
294
+ result.errors.push({ name: file.name, error: err.message });
295
+ }
296
+ }
297
+ // Drop user-layer copies from this project that no longer exist in project YAML.
298
+ for (const job of listJobs()) {
299
+ if (job.source?.kind !== 'project')
300
+ continue;
301
+ if (expandProjectPath(job.source.projectPath) !== abs)
302
+ continue;
303
+ if (seenNames.has(job.name))
304
+ continue;
305
+ if (deleteJob(job.name))
306
+ result.removed.push(job.name);
307
+ }
308
+ return result;
309
+ }
310
+ /**
311
+ * Sync every project root on the user allowlist (`meta.routines.projects`),
312
+ * plus any explicit `extraRoots`. Project `agents.yaml` `routines.enable`
313
+ * alone never opts a path in — enable-project is required.
314
+ */
315
+ export function syncAllProjectRoutines(opts = {}) {
316
+ const roots = new Set(listEnabledProjectRoots());
317
+ for (const r of opts.extraRoots ?? [])
318
+ roots.add(expandProjectPath(r));
319
+ const projects = [];
320
+ const missing = [];
321
+ for (const root of roots) {
322
+ if (!fs.existsSync(root)) {
323
+ missing.push(root);
324
+ continue;
325
+ }
326
+ projects.push(syncProjectRoutines(root));
327
+ }
328
+ return { projects, missing };
329
+ }
330
+ /**
331
+ * Discover project routines at cwd for the setup UX. Returns null when no
332
+ * project `.agents/routines` dir exists.
333
+ */
334
+ export function discoverProjectRoutinesAt(cwd = process.cwd()) {
335
+ const projectRoot = resolveProjectRoot(cwd);
336
+ if (!projectRoot)
337
+ return null;
338
+ const routinesDir = getProjectRoutinesDir(projectRoot);
339
+ if (!routinesDir || !fs.existsSync(routinesDir))
340
+ return null;
341
+ const files = listProjectRoutineFiles(projectRoot);
342
+ if (files.length === 0)
343
+ return null;
344
+ return {
345
+ projectRoot,
346
+ files,
347
+ enabled: isProjectRoutinesEnabled(projectRoot) || projectAgentsYamlEnablesRoutines(projectRoot),
348
+ };
349
+ }
@@ -14,6 +14,29 @@ export interface JobAllowConfig {
14
14
  sites?: string[];
15
15
  dirs?: string[];
16
16
  }
17
+ /**
18
+ * Where a routine's job body executes when the daemon fires it.
19
+ * Distinct from `devices` (which daemon may *fire*) and from the CLI `--host`
20
+ * remote-management passthrough (manage routines *on* another machine).
21
+ */
22
+ export type HostStrategy = 'local' | 'host' | 'fleet' | 'cloud';
23
+ export declare const HOST_STRATEGIES: readonly HostStrategy[];
24
+ /**
25
+ * Provenance for a routine that was materialised from a project
26
+ * (`.agents/routines/*.yml` synced into the user layer after opt-in).
27
+ */
28
+ export interface JobSource {
29
+ /** Always `project` today; reserved for future layers. */
30
+ kind: 'project';
31
+ /** Absolute path to the project root that owns the YAML. */
32
+ projectPath: string;
33
+ /** GitHub `owner/repo` when the project has a GitHub origin remote. */
34
+ repo?: string;
35
+ /** Branch at last sync (when known). */
36
+ branch?: string;
37
+ /** Short commit SHA at last sync (when known). */
38
+ commit?: string;
39
+ }
17
40
  /** GitHub webhook events a routine can be triggered by. */
18
41
  export type GithubTriggerEvent = 'pull_request' | 'push' | 'issue_comment' | 'workflow_run';
19
42
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
@@ -102,10 +125,33 @@ export interface JobConfig {
102
125
  * `host` says where the dispatched run EXECUTES. CLI flag: `--run-on`
103
126
  * (`--host` on routines commands already means "manage routines on that
104
127
  * machine" via the remote passthrough).
128
+ *
129
+ * When `hostStrategy` is set, it owns placement semantics; `host` is then
130
+ * only required for `hostStrategy: host` (or when strategy is inferred from
131
+ * a bare `host:` field for back-compat).
105
132
  */
106
133
  host?: string;
134
+ /**
135
+ * Where the job body should run when the daemon fires it.
136
+ * - `local` — on the firing machine (default / current behavior)
137
+ * - `host` — on the named `host` over SSH (maps to `--run-on`)
138
+ * - `fleet` — pick one online registered device per run (no cross-device
139
+ * double-fire; the firing pin stays on `devices`)
140
+ * - `cloud` — dispatch via the agent's native cloud provider
141
+ *
142
+ * CLI flag: `--placement` (not `--host`, which is the remote-management
143
+ * passthrough). Omitted strategy falls back to `host` when `host:` is set,
144
+ * otherwise `local`.
145
+ */
146
+ hostStrategy?: HostStrategy;
107
147
  /** Working directory on the host for `host:`-placed runs. */
108
148
  remoteCwd?: string;
149
+ /**
150
+ * Provenance for routines materialised from a project
151
+ * (`<project>/.agents/routines/*.yml` → user-layer copy after opt-in).
152
+ * Absent for hand-authored user/system routines.
153
+ */
154
+ source?: JobSource;
109
155
  variables?: Record<string, string>;
110
156
  sandbox?: boolean;
111
157
  allow?: JobAllowConfig;
@@ -166,6 +212,10 @@ export interface RunMeta {
166
212
  /** The host-task sidecar id backing a `host:` run; the daemon monitor
167
213
  * finalizes the run by reconciling it against the remote `.exit`. */
168
214
  hostTaskId?: string;
215
+ /** Cloud provider task id when `hostStrategy: cloud` dispatched the run. */
216
+ cloudTaskId?: string;
217
+ /** Cloud provider id when the run was cloud-dispatched. */
218
+ cloudProvider?: string;
169
219
  }
170
220
  /**
171
221
  * Finalize a run record with a terminal status, computing `duration` from
@@ -184,6 +234,24 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
184
234
  * catchup/overdue, manual run) gates on this.
185
235
  */
186
236
  export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
237
+ /**
238
+ * Resolve the effective host strategy for a job.
239
+ * Bare `host:` without an explicit strategy implies `host` (back-compat with
240
+ * pre-hostStrategy YAML). Otherwise default to `local`.
241
+ */
242
+ export declare function resolveHostStrategy(config: Pick<JobConfig, 'hostStrategy' | 'host'>): HostStrategy;
243
+ /**
244
+ * Parse a CLI `--placement` value into a HostStrategy, or null when empty.
245
+ * Throws a human-readable Error for unknown values.
246
+ */
247
+ export declare function parseHostStrategy(raw: string | undefined | null): HostStrategy | null;
248
+ /**
249
+ * Strategies that dispatch the job body off the firing machine. Without a
250
+ * `devices` pin every daemon in the fleet would fire and each would dispatch
251
+ * once — N× duplicate runs. Callers pin to this machine when the user did not
252
+ * set an explicit allowlist.
253
+ */
254
+ export declare function placementRequiresFiringPin(strategy: HostStrategy): boolean;
187
255
  /** Human presentation of a device-affinity mismatch for commands and runner. */
188
256
  export interface JobEligibilityResult {
189
257
  /** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
@@ -15,6 +15,7 @@ import { safeJoin, isSafeSegmentName } from './paths.js';
15
15
  import { atomicWriteFileSync } from './fs-atomic.js';
16
16
  import { ALL_AGENT_IDS } from './agents.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
+ export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
18
19
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
19
20
  export const GITHUB_TRIGGER_EVENTS = [
20
21
  'pull_request',
@@ -84,6 +85,39 @@ export function jobRunsOnThisDevice(config) {
84
85
  const self = machineId();
85
86
  return config.devices.some((d) => normalizeHost(d) === self);
86
87
  }
88
+ /**
89
+ * Resolve the effective host strategy for a job.
90
+ * Bare `host:` without an explicit strategy implies `host` (back-compat with
91
+ * pre-hostStrategy YAML). Otherwise default to `local`.
92
+ */
93
+ export function resolveHostStrategy(config) {
94
+ if (config.hostStrategy)
95
+ return config.hostStrategy;
96
+ if (config.host)
97
+ return 'host';
98
+ return 'local';
99
+ }
100
+ /**
101
+ * Parse a CLI `--placement` value into a HostStrategy, or null when empty.
102
+ * Throws a human-readable Error for unknown values.
103
+ */
104
+ export function parseHostStrategy(raw) {
105
+ if (raw === undefined || raw === null || raw === '')
106
+ return null;
107
+ const v = raw.trim().toLowerCase();
108
+ if (HOST_STRATEGIES.includes(v))
109
+ return v;
110
+ throw new Error(`Invalid placement '${raw}'. Use one of: ${HOST_STRATEGIES.join(', ')}`);
111
+ }
112
+ /**
113
+ * Strategies that dispatch the job body off the firing machine. Without a
114
+ * `devices` pin every daemon in the fleet would fire and each would dispatch
115
+ * once — N× duplicate runs. Callers pin to this machine when the user did not
116
+ * set an explicit allowlist.
117
+ */
118
+ export function placementRequiresFiringPin(strategy) {
119
+ return strategy === 'host' || strategy === 'fleet' || strategy === 'cloud';
120
+ }
87
121
  /**
88
122
  * Return null when the job may run here; otherwise return a structured,
89
123
  * human-friendly eligibility failure. Centralizes the message/suggestion
@@ -419,24 +453,48 @@ export function validateJob(config) {
419
453
  if (config.device !== undefined) {
420
454
  errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
421
455
  }
456
+ if (config.hostStrategy !== undefined) {
457
+ if (!HOST_STRATEGIES.includes(config.hostStrategy)) {
458
+ errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
459
+ }
460
+ }
461
+ const strategy = resolveHostStrategy(config);
422
462
  if (config.host !== undefined) {
423
463
  if (typeof config.host !== 'string' || config.host.trim() === '') {
424
464
  errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
425
465
  }
426
- // v1: the workflow bundle and the loop driver (with its signal files) live
427
- // on the firing machine neither can cross SSH to the target yet.
466
+ }
467
+ if (strategy === 'host' && (!config.host || config.host.trim() === '')) {
468
+ errors.push("hostStrategy: host requires host: (set via --run-on or host: in YAML)");
469
+ }
470
+ // Remote placement (host/fleet/cloud) can't carry workflow/loop/command yet —
471
+ // those live on the firing machine.
472
+ if (strategy === 'host' || strategy === 'fleet' || strategy === 'cloud') {
428
473
  if (config.workflow) {
429
- errors.push("host: can't be combined with workflow: yet (the bundle lives on the firing machine) — run the workflow locally or convert it to a plain prompt");
474
+ errors.push(`${strategy} placement can't be combined with workflow: yet — run the workflow locally or convert it to a plain prompt`);
430
475
  }
431
476
  if (config.loop) {
432
- errors.push("host: can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)");
477
+ errors.push(`${strategy} placement can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)`);
433
478
  }
434
479
  if (config.command) {
435
- errors.push("host: can't be combined with command: yet (a plain shell command has no agent to place remotely) — run it locally, or convert it to a prompt");
480
+ errors.push(`${strategy} placement can't be combined with command: yet (a plain shell command has no agent to place remotely)`);
436
481
  }
437
482
  }
438
- if (config.remoteCwd !== undefined && config.host === undefined) {
439
- errors.push('remoteCwd only applies to host:-placed routines — set host: too, or drop it');
483
+ if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
484
+ errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
485
+ }
486
+ if (config.source !== undefined) {
487
+ if (!config.source || typeof config.source !== 'object') {
488
+ errors.push('source must be an object');
489
+ }
490
+ else {
491
+ if (config.source.kind !== 'project') {
492
+ errors.push("source.kind must be 'project'");
493
+ }
494
+ if (typeof config.source.projectPath !== 'string' || config.source.projectPath.trim() === '') {
495
+ errors.push('source.projectPath must be a non-empty absolute path');
496
+ }
497
+ }
440
498
  }
441
499
  if (config.devices !== undefined) {
442
500
  if (!Array.isArray(config.devices)) {
@@ -451,6 +509,15 @@ export function validateJob(config) {
451
509
  }
452
510
  }
453
511
  }
512
+ // Off-box placement without a devices pin fires on every fleet daemon and
513
+ // each dispatches once (RUSH-1980). Enforce the pin at validation so hand
514
+ // edits and devices --clear cannot re-open the hole.
515
+ if (placementRequiresFiringPin(strategy)) {
516
+ if (!config.devices || config.devices.length === 0) {
517
+ errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
518
+ '(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
519
+ }
520
+ }
454
521
  return errors;
455
522
  }
456
523
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -77,9 +77,19 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
77
77
  * failover across healthy same-agent accounts (RUSH-1016).
78
78
  */
79
79
  export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
80
+ /**
81
+ * Optional lifecycle callbacks for a detached routine run. The daemon passes an
82
+ * `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
83
+ * runs from the in-process settle() when the child exits, the only seam that
84
+ * observes the live running→terminal transition (the monitor tick would already
85
+ * see a finalized record and skip it). Never let a hook throw into finalization.
86
+ */
87
+ export interface RoutineHooks {
88
+ /** Called once with the finalized meta when the run reaches a terminal state. */
89
+ onFinish?: (meta: RunMeta) => void;
90
+ }
80
91
  /** Spawn a job as a detached process and return immediately with run metadata. */
81
- /** Spawn a job as a detached process and return immediately with run metadata. */
82
- export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
92
+ export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
83
93
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
84
94
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
85
95
  /** Scan all runs marked "running" and finalize any whose process has exited. */