@phnx-labs/agents-cli 1.22.31 → 1.22.33

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 (69) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +8 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/routines.js +164 -36
  8. package/dist/commands/sessions-browser.js +2 -2
  9. package/dist/commands/sessions.d.ts +1 -1
  10. package/dist/commands/sessions.js +66 -22
  11. package/dist/commands/update.d.ts +2 -0
  12. package/dist/commands/update.js +148 -0
  13. package/dist/index.js +3 -1
  14. package/dist/lib/catchup.js +4 -1
  15. package/dist/lib/daemon.d.ts +17 -0
  16. package/dist/lib/daemon.js +69 -3
  17. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  18. package/dist/lib/devices/doctor-findings.js +53 -2
  19. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  20. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  22. package/dist/lib/devices/fleet-divergence.js +6 -0
  23. package/dist/lib/devices/fleet-inventory.js +16 -2
  24. package/dist/lib/drift.d.ts +6 -1
  25. package/dist/lib/drift.js +9 -0
  26. package/dist/lib/hooks/cache.js +20 -1
  27. package/dist/lib/hooks.d.ts +91 -1
  28. package/dist/lib/hooks.js +289 -3
  29. package/dist/lib/hosts/passthrough.js +3 -0
  30. package/dist/lib/installations/index.d.ts +14 -0
  31. package/dist/lib/installations/index.js +14 -0
  32. package/dist/lib/installations/resolve.d.ts +43 -0
  33. package/dist/lib/installations/resolve.js +93 -0
  34. package/dist/lib/installations/store.d.ts +56 -0
  35. package/dist/lib/installations/store.js +196 -0
  36. package/dist/lib/installations/strategies.d.ts +73 -0
  37. package/dist/lib/installations/strategies.js +293 -0
  38. package/dist/lib/installations/types.d.ts +78 -0
  39. package/dist/lib/installations/types.js +8 -0
  40. package/dist/lib/installations/update.d.ts +40 -0
  41. package/dist/lib/installations/update.js +131 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +27 -0
  45. package/dist/lib/migrate.js +112 -2
  46. package/dist/lib/routine-context.d.ts +144 -0
  47. package/dist/lib/routine-context.js +268 -0
  48. package/dist/lib/routine-readiness.d.ts +47 -0
  49. package/dist/lib/routine-readiness.js +239 -0
  50. package/dist/lib/routines.d.ts +97 -1
  51. package/dist/lib/routines.js +107 -1
  52. package/dist/lib/runner.d.ts +18 -4
  53. package/dist/lib/runner.js +291 -98
  54. package/dist/lib/scheduler.d.ts +7 -1
  55. package/dist/lib/scheduler.js +5 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  59. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  60. package/dist/lib/self-heal/registry.js +5 -2
  61. package/dist/lib/self-heal/types.d.ts +1 -1
  62. package/dist/lib/session/state.js +4 -1
  63. package/dist/lib/session/team-filter.d.ts +11 -0
  64. package/dist/lib/session/team-filter.js +10 -0
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +2 -0
  67. package/dist/lib/versions.d.ts +24 -0
  68. package/dist/lib/versions.js +49 -16
  69. package/package.json +2 -2
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Activation readiness for a routine, composed from the target-aware execution
3
+ * context ({@link resolveJobExecutionContext}) plus the harness/target checks a
4
+ * caller can perform on this box (agent installed). This is the gate `add`,
5
+ * `edit`, `doctor`, and `resume` all run before activating a routine: ready →
6
+ * active, any proven blocker → saved paused with a stable code + repair command.
7
+ *
8
+ * Structural context readiness is synchronous for the scheduler. Interactive
9
+ * add/edit/doctor/resume additionally call the live variant below, which probes
10
+ * authentication and Codex's native workspace-trust record before activation.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import * as TOML from 'smol-toml';
15
+ import { resolveJobExecutionContext, resolveHostStrategy } from './routines.js';
16
+ import { evaluateRoutineReadiness } from './routine-context.js';
17
+ import { getVersionHomePath, resolveVersion } from './versions.js';
18
+ import { probeLocalFleetAuth } from './auth-health.js';
19
+ import { resolveHostRunTarget } from './hosts/run-target.js';
20
+ import { hostIdentityArgs, sshTargetFor } from './hosts/types.js';
21
+ import { probeHost } from './hosts/ready.js';
22
+ import { sshExec, shellQuote } from './ssh-exec.js';
23
+ import { encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './hosts/remote-cmd.js';
24
+ /**
25
+ * Evaluate whether a routine is ready to activate on this box. `probeAgent`
26
+ * defaults to "is a version of the routine's agent resolvable" via
27
+ * {@link resolveVersion}; pass a stub in tests to exercise the availability path
28
+ * without an installed harness.
29
+ */
30
+ export function evaluateActivationReadiness(config, deps = {}) {
31
+ const strategy = resolveHostStrategy(config);
32
+ const mode = strategy;
33
+ // Local placement inspects this box's filesystem; a remote/cloud target defers
34
+ // existence (its filesystem is unreachable here) and checks portability only.
35
+ const context = resolveJobExecutionContext(config, {
36
+ mode,
37
+ probe: mode === 'local' ? undefined : null,
38
+ });
39
+ const probeAgent = deps.probeAgent ?? ((agent) => resolveVersion(agent) !== undefined);
40
+ return evaluateRoutineReadiness(context, {
41
+ // Command routines have no agent to install; workflow routines dispatch
42
+ // through `agents run` (claude under the hood) and are checked at run time.
43
+ agentInstalled: config.agent && !config.workflow && !config.command
44
+ ? () => probeAgent(config.agent)
45
+ : undefined,
46
+ }, { agent: config.agent });
47
+ }
48
+ /** One-line human summary of a blocked readiness result, with its repair. */
49
+ export function formatReadinessBlocker(result) {
50
+ if (result.ready || !result.readiness)
51
+ return 'ready';
52
+ const { code, message, repair } = result.readiness;
53
+ return `${code}: ${message}${repair ? `\n repair: ${repair}` : ''}`;
54
+ }
55
+ /** Parse the target HOME sentinel and the project catalog returned by one SSH call. */
56
+ export function parseRemoteProjectSnapshot(stdout) {
57
+ const lines = stdout.split(/\r?\n/);
58
+ const homeLine = lines.shift();
59
+ if (!homeLine?.startsWith('__HOME__'))
60
+ return undefined;
61
+ const home = homeLine.slice('__HOME__'.length);
62
+ if (!home)
63
+ return undefined;
64
+ try {
65
+ const projects = JSON.parse(lines.join('\n'));
66
+ if (!Array.isArray(projects))
67
+ return undefined;
68
+ if (!projects.every((entry) => entry && typeof entry === 'object' && typeof entry.name === 'string')) {
69
+ return undefined;
70
+ }
71
+ return { home, projects: projects };
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ /** Build a target-native probe that creates and removes a file in the workspace. */
78
+ export function buildRemoteWorkspaceProbe(cwd, windows) {
79
+ if (windows) {
80
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(`$d=${powershellQuote(cwd)}; if (-not (Test-Path -LiteralPath $d -PathType Container)) { exit 1 }; $p=Join-Path $d ([IO.Path]::GetRandomFileName()); try { New-Item -ItemType File -Path $p -ErrorAction Stop | Out-Null; Remove-Item -LiteralPath $p -Force -ErrorAction Stop; exit 0 } catch { exit 1 }`)}`;
81
+ }
82
+ const pattern = path.posix.join(cwd, '.agents-routine-readiness.XXXXXX');
83
+ return `probe_path=$(mktemp ${shellQuote(pattern)}) && rm -f "$probe_path"`;
84
+ }
85
+ /** A successful probe must contain the exact requested postcondition, not merely exit zero. */
86
+ export function probeOutputHasSentinel(stdout, sentinel) {
87
+ const containsExact = (value) => {
88
+ if (value === sentinel)
89
+ return true;
90
+ if (Array.isArray(value))
91
+ return value.some(containsExact);
92
+ if (value && typeof value === 'object')
93
+ return Object.values(value).some(containsExact);
94
+ return false;
95
+ };
96
+ return stdout.split(/\r?\n/).some((line) => {
97
+ const trimmed = line.trim();
98
+ if (trimmed === sentinel)
99
+ return true;
100
+ try {
101
+ return containsExact(JSON.parse(trimmed));
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ });
107
+ }
108
+ function codexWorkspaceTrusted(version, cwd) {
109
+ try {
110
+ const configPath = path.join(getVersionHomePath('codex', version), '.codex', 'config.toml');
111
+ const parsed = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
112
+ const projects = parsed.projects;
113
+ return Object.entries(projects ?? {}).some(([root, project]) => {
114
+ if (project.trust_level !== 'trusted')
115
+ return false;
116
+ const relative = path.relative(root, cwd);
117
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
118
+ });
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ /**
125
+ * Interactive setup/repair readiness. Unlike the scheduler's deterministic
126
+ * structural gate, this completes a real local auth request and reads Codex's
127
+ * native trust record before add/edit/resume can activate the definition.
128
+ */
129
+ export async function evaluateActivationReadinessLive(config) {
130
+ const structural = evaluateActivationReadiness(config);
131
+ if (!structural.ready)
132
+ return structural;
133
+ const mode = resolveHostStrategy(config);
134
+ if (mode === 'host' && config.host)
135
+ return evaluateHostActivationReadiness(config);
136
+ if (mode !== 'local' || !config.agent || config.workflow || config.command)
137
+ return structural;
138
+ const version = resolveVersion(config.agent);
139
+ if (!version)
140
+ return structural;
141
+ const context = resolveJobExecutionContext(config, { mode: 'local' });
142
+ let authVerdict;
143
+ const rows = await probeLocalFleetAuth({ agents: [config.agent] });
144
+ const row = rows.find((candidate) => candidate.version === version);
145
+ const accepted = new Set(['live', 'rate_limited', 'unverified']);
146
+ authVerdict = row && accepted.has(row.health.verdict)
147
+ ? { ok: true }
148
+ : { ok: false, reason: row?.health.verdict ?? 'unconfigured' };
149
+ return evaluateRoutineReadiness(context, {
150
+ agentInstalled: () => true,
151
+ ...(config.agent === 'codex' && context.absoluteCwd
152
+ ? { codexTrusted: () => codexWorkspaceTrusted(version, context.absoluteCwd) }
153
+ : {}),
154
+ authOk: () => authVerdict,
155
+ }, { agent: config.agent });
156
+ }
157
+ /** Resolve and probe the actual SSH target used by a host-placed routine. */
158
+ export async function evaluateHostActivationReadiness(config) {
159
+ const host = await resolveHostRunTarget(config.host);
160
+ const target = sshTargetFor(host);
161
+ const identity = hostIdentityArgs(host);
162
+ if (!probeHost(target, host.os, identity).reachable) {
163
+ return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
164
+ targetReachable: () => false,
165
+ }, { agent: config.agent });
166
+ }
167
+ const windows = host.os?.toLowerCase().includes('win') ?? false;
168
+ const homeCommand = windows
169
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; Write-Output ("__HOME__" + $HOME); & agents projects list --json`)}`
170
+ : `bash -lc ${shellQuote('printf "__HOME__%s\\n" "$HOME"; agents projects list --json')}`;
171
+ const projectResult = sshExec(target, homeCommand, { timeoutMs: 20_000, extraSshArgs: identity });
172
+ if (projectResult.code !== 0) {
173
+ return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
174
+ targetReachable: () => false,
175
+ }, { agent: config.agent });
176
+ }
177
+ const snapshot = parseRemoteProjectSnapshot(projectResult.stdout);
178
+ if (!snapshot) {
179
+ return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
180
+ targetReachable: () => false,
181
+ }, { agent: config.agent });
182
+ }
183
+ const targetHome = snapshot.home;
184
+ const defs = snapshot.projects;
185
+ const def = config.project ? defs.find((candidate) => candidate.name === config.project) : undefined;
186
+ const projectResolution = config.project
187
+ ? (def ? { defined: true, base: def.defaultPath ?? def.root } : { defined: false })
188
+ : undefined;
189
+ const unprobed = resolveJobExecutionContext(config, { mode: 'host', targetHome, probe: null, projectResolution });
190
+ if (!unprobed.ready || !unprobed.absoluteCwd)
191
+ return { context: unprobed, ready: false, readiness: unprobed.readiness };
192
+ const check = buildRemoteWorkspaceProbe(unprobed.absoluteCwd, windows);
193
+ const fsResult = sshExec(target, check, { timeoutMs: 12_000, extraSshArgs: identity });
194
+ if (fsResult.code !== 0) {
195
+ return evaluateRoutineReadiness({ ...unprobed, ready: false, readiness: {
196
+ code: 'workspace_not_writable',
197
+ message: `the execution directory is missing or not writable on ${host.name}: ${unprobed.resolvedCwd}`,
198
+ } });
199
+ }
200
+ if (config.agent && !config.workflow && !config.command) {
201
+ if (config.agent === 'codex') {
202
+ const args = ['run', 'codex', 'Reply with exactly ROUTINE_READY', '--mode', 'plan', '--timeout', '45s', '--json'];
203
+ const command = windows
204
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; Set-Location -LiteralPath ${powershellQuote(unprobed.absoluteCwd)}; & agents ${args.map(powershellQuote).join(' ')}`)}`
205
+ : `cd ${shellQuote(unprobed.absoluteCwd)} && agents ${args.map(shellQuote).join(' ')}`;
206
+ const probe = sshExec(target, command, { timeoutMs: 60_000, extraSshArgs: identity });
207
+ if (probe.code !== 0 || !probeOutputHasSentinel(probe.stdout, 'ROUTINE_READY')) {
208
+ const detail = `${probe.stderr}\n${probe.stdout}`.trim();
209
+ const untrusted = detail.includes('trusted directory') || detail.includes('trusted workspace');
210
+ return evaluateRoutineReadiness(unprobed, {
211
+ ...(untrusted ? { codexTrusted: () => false } : { authOk: () => ({ ok: false, reason: detail || 'probe failed' }) }),
212
+ }, { agent: config.agent });
213
+ }
214
+ }
215
+ else {
216
+ const pingArgs = ['devices', 'ping', '--local', '--json'];
217
+ const command = windows
218
+ ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; & agents ${pingArgs.map(powershellQuote).join(' ')}`)}`
219
+ : `agents ${pingArgs.map(shellQuote).join(' ')}`;
220
+ const probe = sshExec(target, command, { timeoutMs: 30_000, extraSshArgs: identity });
221
+ let verdict = 'error';
222
+ if (probe.code === 0) {
223
+ try {
224
+ const payload = JSON.parse(probe.stdout);
225
+ verdict = payload.rows?.find((row) => row.agent === config.agent)?.health.verdict ?? 'unconfigured';
226
+ }
227
+ catch {
228
+ verdict = 'error';
229
+ }
230
+ }
231
+ if (!new Set(['live', 'rate_limited', 'unverified']).has(verdict)) {
232
+ return evaluateRoutineReadiness(unprobed, {
233
+ authOk: () => ({ ok: false, reason: verdict }),
234
+ }, { agent: config.agent });
235
+ }
236
+ }
237
+ }
238
+ return { context: unprobed, ready: true };
239
+ }
@@ -6,6 +6,7 @@
6
6
  * run metadata persistence, prompt variable expansion, and one-shot "at" time
7
7
  * scheduling.
8
8
  */
9
+ import { type ResolvedExecutionContext, type ProjectResolution, type PlacementMode, type RoutineKind, type ContextFsProbe } from './routine-context.js';
9
10
  import type { AgentId } from './types.js';
10
11
  import type { LoopConfig } from './loop.js';
11
12
  /** Tool/site/directory allow-list for sandboxed job execution. */
@@ -114,6 +115,32 @@ export interface JobConfig {
114
115
  prompt: string;
115
116
  timezone?: string;
116
117
  repo?: string;
118
+ /**
119
+ * Singular execution anchor: the named project (`agents projects`) whose base
120
+ * directory the routine's run lands in. Optional. Metadata-only `projects[]`
121
+ * (below) is NEVER used for execution — this field is. Resolution happens on
122
+ * the execution TARGET (`resolveRoutineExecutionContext`, routine-context.ts),
123
+ * never from the daemon's own cwd: a project with a usable `defaultPath`/`root`
124
+ * gives the base directory; a rootless Linear-imported project gives no base,
125
+ * so a bare relative `cwd` then anchors at the target user's `$HOME`.
126
+ *
127
+ * CLI flag is `--project-anchor` (not `--project`, which is the repeatable
128
+ * grouping-metadata flag that writes `projects[]`). The YAML key is the shorter
129
+ * singular `project` because it is unambiguous there.
130
+ */
131
+ project?: string;
132
+ /**
133
+ * Portable execution directory for the routine's run. Optional. A relative
134
+ * value resolves under the `project` base when that base is usable, otherwise
135
+ * under the execution target's `$HOME` (so a Linear-imported rootless project
136
+ * can still name `cwd: src/github.com/acme/app`). A `~/`-anchored value is the
137
+ * target's home-relative path; an absolute path under the target home is
138
+ * normalized to the portable `~/…` form on save. An absolute path outside the
139
+ * home is only allowed for local-pinned routines — host/fleet/cloud placement
140
+ * pauses it as non-portable. Supersedes the legacy `remoteCwd`, which the
141
+ * one-shot migration folds into this field.
142
+ */
143
+ cwd?: string;
117
144
  /**
118
145
  * Fleet allowlist — restrict this routine to specific devices. When omitted
119
146
  * or empty, the routine is unrestricted and fires on every device running the
@@ -308,6 +335,24 @@ export declare function projectGroupOrder(group: ProjectGroup): number;
308
335
  * - `"Unknown projects"` — when any entry is no longer a defined project (stale).
309
336
  */
310
337
  export declare function computeProjectGroup(projects: string[] | undefined, knownProjectNames: Set<string>): string;
338
+ /** A real-filesystem {@link ContextFsProbe} for readiness checks on this machine. */
339
+ export declare function realFsProbe(): ContextFsProbe;
340
+ /** Classify a routine by its body kind — governs the execution-context fallback rules. */
341
+ export declare function jobRoutineKind(config: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
342
+ /**
343
+ * Resolve a routine's execution context (working directory + structural/fs
344
+ * readiness) by bridging its `project`/`cwd` fields into the pure
345
+ * {@link resolveRoutineExecutionContext} resolver. Local placement resolves
346
+ * against this machine's `$HOME` with a real filesystem probe; a caller may
347
+ * inject a different target home / probe (e.g. `null` to defer existence for a
348
+ * remote target).
349
+ */
350
+ export declare function resolveJobExecutionContext(config: Pick<JobConfig, 'name' | 'project' | 'cwd' | 'agent' | 'workflow' | 'command'>, opts?: {
351
+ targetHome?: string;
352
+ mode?: PlacementMode;
353
+ probe?: ContextFsProbe | null;
354
+ projectResolution?: ProjectResolution;
355
+ }): ResolvedExecutionContext;
311
356
  /** Metadata for a single job execution, persisted as JSON in the run directory. */
312
357
  export interface RunMeta {
313
358
  jobName: string;
@@ -337,8 +382,41 @@ export interface RunMeta {
337
382
  * due). Without it a miss leaves no trace at all and the listing keeps
338
383
  * showing the previous run's status as if it were current. Written by
339
384
  * `claimMissedFire` (catchup.ts), never by the runner.
385
+ *
386
+ * `blocked` and `skipped` are pre-execution terminals that leave a visible
387
+ * record even though no agent process ran (the plan's history contract):
388
+ * - `blocked` — a fire-time readiness rejection (bad context, dead auth,
389
+ * untrusted workspace). No agent process was spawned. Distinct from `failed`,
390
+ * which means a process started and failed.
391
+ * - `skipped` — the attempt lost a claim (`skipReason`): a duplicate schedule
392
+ * slot, an already-active run it would overlap, or a wrong device owner.
393
+ */
394
+ status: 'running' | 'completed' | 'failed' | 'timeout' | 'missed' | 'blocked' | 'skipped';
395
+ /**
396
+ * How this attempt was triggered. Answers "why did this run exist" for a
397
+ * record that may have no transcript (a blocked/skipped attempt).
398
+ */
399
+ triggerKind?: 'schedule' | 'catchup' | 'manual' | 'webhook' | 'event';
400
+ /**
401
+ * The scheduler's intended UTC fire time (ISO), for a `schedule`/`catchup`
402
+ * attempt. The atomic single-fire claim keys on (routine, scheduledFor): a
403
+ * duplicate cron delivery for the same slot resolves to this same run rather
404
+ * than launching a second time.
340
405
  */
341
- status: 'running' | 'completed' | 'failed' | 'timeout' | 'missed';
406
+ scheduledFor?: string;
407
+ /** Resolved execution context (routine-context.ts), recorded before preflight. */
408
+ project?: string;
409
+ requestedCwd?: string;
410
+ resolvedCwd?: string;
411
+ readiness?: {
412
+ code: string;
413
+ message: string;
414
+ repair?: string;
415
+ };
416
+ /** Why a `skipped` attempt launched nothing. */
417
+ skipReason?: 'duplicate_slot' | 'active_run' | 'wrong_owner';
418
+ /** The run this attempt deferred to (the winning duplicate slot / active run). */
419
+ activeRunId?: string;
342
420
  startedAt: string;
343
421
  completedAt: string | null;
344
422
  exitCode: number | null;
@@ -629,6 +707,24 @@ export declare function readRunMeta(jobName: string, runId: string): RunMeta | n
629
707
  export declare function getJobRunsDir(jobName: string): string;
630
708
  /** Get the filesystem path for a specific run's directory. */
631
709
  export declare function getRunDir(jobName: string, runId: string): string;
710
+ /**
711
+ * The run id a scheduled fire is recorded under — derived from its intended UTC
712
+ * fire time so the SAME slot always maps to the SAME run directory. This is what
713
+ * makes the single-fire claim meaningful: a duplicate cron delivery for one slot
714
+ * computes the same id and loses the atomic `mkdir` claim. Shares the derivation
715
+ * with `missedRunId` (catchup.ts) so a missed-then-caught-up fire and a live fire
716
+ * for the same UTC slot are one record.
717
+ */
718
+ export declare function slotRunId(scheduledFor: Date | string): string;
719
+ /**
720
+ * Atomically CLAIM a run directory. Returns true on a successful claim, false
721
+ * when the directory already exists (another caller — even in a separate process
722
+ * — owns this (routine, slot) pair). The non-recursive `mkdir` is a single
723
+ * filesystem test-and-set on every POSIX filesystem, the same primitive
724
+ * `claimMissedFire` relies on; it holds across processes where an in-process flag
725
+ * or a released lock cannot.
726
+ */
727
+ export declare function claimRunSlot(jobName: string, runId: string): boolean;
632
728
  /** Discover routine YAML files in a repository's routines/ directory. */
633
729
  export declare function discoverJobsFromRepo(repoPath: string): Array<{
634
730
  name: string;
@@ -11,8 +11,10 @@ import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
+ import * as os from 'os';
14
15
  import { safeJoin, isSafeSegmentName } from './paths.js';
15
- import { isSafeProjectName } from './projects.js';
16
+ import { isSafeProjectName, loadProjectDef, projectBasePath } from './projects.js';
17
+ import { resolveRoutineExecutionContext, } from './routine-context.js';
16
18
  import { atomicWriteFileSync } from './fs-atomic.js';
17
19
  import { ALL_AGENT_IDS, ROUTINE_AGENT_IDS } from './agents.js';
18
20
  import { machineId, normalizeHost } from './machine-id.js';
@@ -142,6 +144,65 @@ export function projectGroupOrder(group) {
142
144
  export function computeProjectGroup(projects, knownProjectNames) {
143
145
  return projectGroupTitle(computeProjectGroupKind(projects, knownProjectNames));
144
146
  }
147
+ /** A real-filesystem {@link ContextFsProbe} for readiness checks on this machine. */
148
+ export function realFsProbe() {
149
+ return {
150
+ exists: (p) => fs.existsSync(p),
151
+ isDirectory: (p) => { try {
152
+ return fs.statSync(p).isDirectory();
153
+ }
154
+ catch {
155
+ return false;
156
+ } },
157
+ isWritable: (p) => { try {
158
+ fs.accessSync(p, fs.constants.W_OK);
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ } },
164
+ };
165
+ }
166
+ /** Classify a routine by its body kind — governs the execution-context fallback rules. */
167
+ export function jobRoutineKind(config) {
168
+ if (config.command)
169
+ return 'command';
170
+ if (config.workflow)
171
+ return 'workflow';
172
+ return 'agent';
173
+ }
174
+ /**
175
+ * Resolve a routine's execution context (working directory + structural/fs
176
+ * readiness) by bridging its `project`/`cwd` fields into the pure
177
+ * {@link resolveRoutineExecutionContext} resolver. Local placement resolves
178
+ * against this machine's `$HOME` with a real filesystem probe; a caller may
179
+ * inject a different target home / probe (e.g. `null` to defer existence for a
180
+ * remote target).
181
+ */
182
+ export function resolveJobExecutionContext(config, opts = {}) {
183
+ const targetHome = opts.targetHome ?? os.homedir();
184
+ const mode = opts.mode ?? 'local';
185
+ const probe = opts.probe === null
186
+ ? undefined
187
+ : (opts.probe ?? (mode === 'local' ? realFsProbe() : undefined));
188
+ let projectResolution = opts.projectResolution;
189
+ if (config.project !== undefined && projectResolution === undefined) {
190
+ const def = loadProjectDef(config.project);
191
+ projectResolution = def
192
+ ? { defined: true, base: projectBasePath(def, true) } // portable (~/) base form
193
+ : { defined: false };
194
+ }
195
+ return resolveRoutineExecutionContext({
196
+ name: config.name,
197
+ project: config.project,
198
+ cwd: config.cwd,
199
+ kind: jobRoutineKind(config),
200
+ mode,
201
+ targetHome,
202
+ projectResolution,
203
+ probe,
204
+ });
205
+ }
145
206
  /**
146
207
  * Finalize a run record with a terminal status, computing `duration` from
147
208
  * `startedAt` and the completion timestamp. Keeps failure-reason population
@@ -699,6 +760,18 @@ export function validateJob(config) {
699
760
  if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
700
761
  errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
701
762
  }
763
+ if (config.project !== undefined && (typeof config.project !== 'string' || config.project.trim() === '')) {
764
+ errors.push('project (the singular execution anchor) must be a non-empty project name');
765
+ }
766
+ if (config.cwd !== undefined && (typeof config.cwd !== 'string' || config.cwd.trim() === '')) {
767
+ errors.push('cwd (the portable execution directory) must be a non-empty path string');
768
+ }
769
+ // `remoteCwd` is the legacy host-placement path; `cwd` is its canonical
770
+ // replacement. The two split path semantics, so they must never coexist — the
771
+ // one-shot migration folds remoteCwd into cwd, and a conflicting pair pauses.
772
+ if (config.cwd !== undefined && config.remoteCwd !== undefined) {
773
+ errors.push('cwd and remoteCwd both set — remoteCwd is the legacy form of cwd; keep only cwd');
774
+ }
702
775
  if (config.source !== undefined) {
703
776
  if (!config.source || typeof config.source !== 'object') {
704
777
  errors.push('source must be an object');
@@ -1168,6 +1241,39 @@ export function getJobRunsDir(jobName) {
1168
1241
  export function getRunDir(jobName, runId) {
1169
1242
  return path.join(getJobRunsDir(jobName), runId);
1170
1243
  }
1244
+ /**
1245
+ * The run id a scheduled fire is recorded under — derived from its intended UTC
1246
+ * fire time so the SAME slot always maps to the SAME run directory. This is what
1247
+ * makes the single-fire claim meaningful: a duplicate cron delivery for one slot
1248
+ * computes the same id and loses the atomic `mkdir` claim. Shares the derivation
1249
+ * with `missedRunId` (catchup.ts) so a missed-then-caught-up fire and a live fire
1250
+ * for the same UTC slot are one record.
1251
+ */
1252
+ export function slotRunId(scheduledFor) {
1253
+ const iso = typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString();
1254
+ return iso.replace(/[:.]/g, '-');
1255
+ }
1256
+ /**
1257
+ * Atomically CLAIM a run directory. Returns true on a successful claim, false
1258
+ * when the directory already exists (another caller — even in a separate process
1259
+ * — owns this (routine, slot) pair). The non-recursive `mkdir` is a single
1260
+ * filesystem test-and-set on every POSIX filesystem, the same primitive
1261
+ * `claimMissedFire` relies on; it holds across processes where an in-process flag
1262
+ * or a released lock cannot.
1263
+ */
1264
+ export function claimRunSlot(jobName, runId) {
1265
+ const runDir = getRunDir(jobName, runId);
1266
+ fs.mkdirSync(path.dirname(runDir), { recursive: true });
1267
+ try {
1268
+ fs.mkdirSync(runDir); // non-recursive: throws EEXIST if already claimed
1269
+ return true;
1270
+ }
1271
+ catch (err) {
1272
+ if (err.code === 'EEXIST')
1273
+ return false;
1274
+ throw err;
1275
+ }
1276
+ }
1171
1277
  /** Discover routine YAML files in a repository's routines/ directory. */
1172
1278
  export function discoverJobsFromRepo(repoPath) {
1173
1279
  const jobsPath = path.join(repoPath, 'routines');
@@ -26,8 +26,22 @@ export interface RunResult {
26
26
  export declare class RoutineAlreadyRunningError extends Error {
27
27
  constructor(jobName: string, runId: string);
28
28
  }
29
- /** Stable working directory for routine children, independent of the daemon's launch cwd. */
30
- export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
29
+ /** How a routine attempt was triggered, plus the schedule slot it belongs to. */
30
+ export interface RoutineTrigger {
31
+ kind: NonNullable<RunMeta['triggerKind']>;
32
+ /** UTC fire time for a schedule/catchup attempt; keys the single-fire slot claim. */
33
+ scheduledFor?: Date | string;
34
+ }
35
+ /**
36
+ * Working directory for a routine's LOCAL child, resolved from its explicit
37
+ * `project`/`cwd` execution anchor via {@link resolveJobExecutionContext} — never
38
+ * inferred from `repo` (which is external repository identity only) and never the
39
+ * daemon's launch cwd. A command routine with neither field lands in `$HOME`
40
+ * (housekeeping); an unresolved/blocked agent context also falls back to `$HOME`
41
+ * so a caller that reaches this (past the readiness gate) has a valid directory,
42
+ * but the gate should have paused such a routine before it ever spawned.
43
+ */
44
+ export declare function routineSpawnCwd(config: Pick<JobConfig, 'name' | 'project' | 'cwd' | 'agent' | 'workflow' | 'command'>): string;
31
45
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
32
46
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
33
47
  /**
@@ -86,7 +100,7 @@ export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow
86
100
  * with `agents run`.
87
101
  */
88
102
  export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string, overlayHome?: string): Record<string, string>;
89
- export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
103
+ export declare function executeJob(config: JobConfig, deps?: LoopDeps, trigger?: RoutineTrigger): Promise<RunResult>;
90
104
  /**
91
105
  * Optional lifecycle callbacks for a detached routine run. The daemon passes an
92
106
  * `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
@@ -99,7 +113,7 @@ export interface RoutineHooks {
99
113
  onFinish?: (meta: RunMeta) => void;
100
114
  }
101
115
  /** Spawn a job as a detached process and return immediately with run metadata. */
102
- export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
116
+ export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks, trigger?: RoutineTrigger): Promise<RunMeta>;
103
117
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
104
118
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
105
119
  /** Derive the final status of a detached run by reading the agent's stream-json