@phnx-labs/agents-cli 1.20.74 → 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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -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;
@@ -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,15 +125,57 @@ 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;
112
158
  config?: Record<string, unknown>;
113
159
  version?: string;
160
+ /**
161
+ * Pin this routine to a signed-in account by identity (its login email, or its
162
+ * account key) instead of rotating. At launch it resolves to whichever
163
+ * installed version currently holds that account and runs pinned — no
164
+ * `balanced` rotation, no usage-read refresh, no failover onto other accounts.
165
+ *
166
+ * This is the durable way to keep concurrent unattended routines off a *shared*
167
+ * Claude OAuth credential: the refresh token is single-use and rotates
168
+ * server-side on every refresh, so two routines running the same account
169
+ * concurrently (on one box or across the fleet) mutually revoke each other —
170
+ * the `401 OAuth access token has been revoked` storm (RUSH-1957). Give each
171
+ * routine (or each device's routines) a distinct account and no run rotates a
172
+ * credential out from under another.
173
+ *
174
+ * Prefer this over `version:`, which pins a version *number* that is GC'd on
175
+ * the next agent upgrade — when the pinned version disappears the routine
176
+ * silently falls back to `balanced` and the stampede returns.
177
+ */
178
+ account?: string;
114
179
  runOnce?: boolean;
115
180
  endAt?: string;
116
181
  /**
@@ -147,6 +212,10 @@ export interface RunMeta {
147
212
  /** The host-task sidecar id backing a `host:` run; the daemon monitor
148
213
  * finalizes the run by reconciling it against the remote `.exit`. */
149
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;
150
219
  }
151
220
  /**
152
221
  * Finalize a run record with a terminal status, computing `duration` from
@@ -165,6 +234,24 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
165
234
  * catchup/overdue, manual run) gates on this.
166
235
  */
167
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;
168
255
  /** Human presentation of a device-affinity mismatch for commands and runner. */
169
256
  export interface JobEligibilityResult {
170
257
  /** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
@@ -251,6 +338,13 @@ export declare function parseTimeout(timeout: string): number | null;
251
338
  export declare function listRuns(jobName: string): RunMeta[];
252
339
  /** Get the most recent run for a job, or null if never run. */
253
340
  export declare function getLatestRun(jobName: string): RunMeta | null;
341
+ /**
342
+ * Get the most recent COMPLETED run for a job, or null if none has completed.
343
+ * Used to resolve `{last_report}` so a failed run's error text (e.g. an auth
344
+ * login prompt written into report.md) can never be injected into the next
345
+ * run's prompt.
346
+ */
347
+ export declare function getLatestCompletedRun(jobName: string): RunMeta | null;
254
348
  /** Persist run metadata to its run directory as meta.json. */
255
349
  export declare function writeRunMeta(meta: RunMeta): void;
256
350
  /** Read run metadata from disk. Returns null if missing or corrupt. */