@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -18,6 +18,10 @@ export interface JobAllowConfig {
18
18
  export type GithubTriggerEvent = 'pull_request' | 'push' | 'issue_comment' | 'workflow_run';
19
19
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
20
20
  export declare const GITHUB_TRIGGER_EVENTS: readonly GithubTriggerEvent[];
21
+ /** Linear webhook resource types a routine can be triggered by. */
22
+ export type LinearTriggerEvent = 'Issue' | 'IssueLabel' | 'Comment' | 'Project' | 'Cycle';
23
+ /** Canonical set of accepted Linear trigger events — single source for validation. */
24
+ export declare const LINEAR_TRIGGER_EVENTS: readonly LinearTriggerEvent[];
21
25
  /**
22
26
  * Map a user-facing `--on` alias to a canonical GitHub trigger event.
23
27
  * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
@@ -26,11 +30,11 @@ export declare const GITHUB_TRIGGER_EVENTS: readonly GithubTriggerEvent[];
26
30
  export declare function normalizeTriggerEvent(input: string): GithubTriggerEvent | null;
27
31
  /**
28
32
  * Event-based fire condition for a routine — an alternative (or complement) to
29
- * `schedule`. Currently only `github_event`: an incoming GitHub webhook whose
30
- * event (and optional repo/branch) match fires the job through the same
31
- * dispatch path a cron fire uses. See `src/lib/triggers/webhook.ts`.
33
+ * `schedule`. Incoming webhooks whose source-specific filters match fire the job
34
+ * through the same dispatch path a cron fire uses. See
35
+ * `src/lib/triggers/webhook.ts`.
32
36
  */
33
- export interface JobTrigger {
37
+ export interface GithubJobTrigger {
34
38
  type: 'github_event';
35
39
  event: GithubTriggerEvent;
36
40
  /** `owner/name` — when set, only payloads for this repo match. */
@@ -38,6 +42,17 @@ export interface JobTrigger {
38
42
  /** git branch (ref short name) — when set, only payloads for this branch match. */
39
43
  branch?: string;
40
44
  }
45
+ export interface LinearJobTrigger {
46
+ type: 'linear_event';
47
+ event: LinearTriggerEvent;
48
+ /** Linear action, e.g. `create`, `update`, `remove`. */
49
+ action?: string;
50
+ /** Issue identifier prefix such as `RUSH`; useful when one webhook spans teams. */
51
+ teamKey?: string;
52
+ /** Required issue label name. */
53
+ label?: string;
54
+ }
55
+ export type JobTrigger = GithubJobTrigger | LinearJobTrigger;
41
56
  /**
42
57
  * Full configuration for a routine (persisted as YAML).
43
58
  *
@@ -51,8 +66,16 @@ export interface JobConfig {
51
66
  schedule?: string;
52
67
  /** Event/webhook fire condition. Optional when `schedule` is set. */
53
68
  trigger?: JobTrigger;
54
- agent: AgentId;
69
+ /** Which agent runs the routine. Optional — omitted for `workflow`/`command` routines. Exactly one of agent/workflow/command must be set. */
70
+ agent?: AgentId;
55
71
  workflow?: string;
72
+ /**
73
+ * A plain shell command run directly instead of an agent/workflow — no LLM,
74
+ * no auth, no rotation, no tokens, no sandbox overlay. For deterministic
75
+ * housekeeping routines (version-check, `npm i -g`, `git pull`, notify).
76
+ * Mutually exclusive with `agent` and `workflow`.
77
+ */
78
+ command?: string;
56
79
  mode: 'plan' | 'edit' | 'auto' | 'skip' | 'full';
57
80
  effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
58
81
  timeout: string;
@@ -89,8 +112,10 @@ export interface JobConfig {
89
112
  export interface RunMeta {
90
113
  jobName: string;
91
114
  runId: string;
92
- agent: AgentId;
115
+ agent?: AgentId;
93
116
  workflow?: string;
117
+ /** The shell command that ran, for command-mode routines (no agent). */
118
+ command?: string;
94
119
  pid: number | null;
95
120
  /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
96
121
  spawnedAt?: number;
@@ -126,16 +151,20 @@ export interface JobEligibilityResult {
126
151
  */
127
152
  export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
128
153
  /**
129
- * List all job configs, scanning project > user routine dirs.
130
- * Project routines (`<project>/.agents/routines/`) shadow user routines of the
131
- * same name. Project discovery is opt-in via `cwd`; the daemon (which calls
132
- * `listJobs()` with no argument) only sees user routines.
154
+ * List all job configs, scanning project > user > system routine dirs.
155
+ * Higher layers shadow lower ones of the same name (first-seen wins): a project
156
+ * routine shadows a user routine, and a user routine shadows a built-in system
157
+ * routine (`~/.agents/.system/routines/`, shipped via gh:phnx-labs/.agents-system).
158
+ * Project discovery is opt-in via `cwd`; the daemon (which calls `listJobs()`
159
+ * with no argument) sees user + system routines, so a built-in routine fires for
160
+ * every install unless the user overrides or disables it by name.
133
161
  */
134
162
  export declare function listJobs(cwd?: string): JobConfig[];
135
163
  /**
136
- * Read a single job config by name, checking project > user.
164
+ * Read a single job config by name, checking project > user > system.
137
165
  * Project discovery is opt-in via `cwd`; daemon callers pass no argument and
138
- * only resolve user routines.
166
+ * resolve user + system routines (a user routine of the same name shadows a
167
+ * built-in system routine).
139
168
  */
140
169
  export declare function readJob(name: string, cwd?: string): JobConfig | null;
141
170
  /** Write a job config to disk, omitting fields that match defaults.
@@ -186,6 +215,15 @@ export declare function getLatestRun(jobName: string): RunMeta | null;
186
215
  export declare function writeRunMeta(meta: RunMeta): void;
187
216
  /** Read run metadata from disk. Returns null if missing or corrupt. */
188
217
  export declare function readRunMeta(jobName: string, runId: string): RunMeta | null;
218
+ /**
219
+ * Runs directory for a single job, with the (untrusted) job name contained to a
220
+ * single segment beneath the runs dir — same guard as `getJobHomePath`. The name
221
+ * comes from routine YAML and can arrive via a synced config repo; every runs-dir
222
+ * sink (run dir, meta read/write, last-report read) routes through here so a
223
+ * crafted `name` like `../../../../tmp/x` can't `mkdirSync`/write `stdout.log`,
224
+ * `meta.json`, or `report.md` outside `~/.agents/.history/runs`.
225
+ */
226
+ export declare function getJobRunsDir(jobName: string): string;
189
227
  /** Get the filesystem path for a specific run's directory. */
190
228
  export declare function getRunDir(jobName: string, runId: string): string;
191
229
  /** Discover routine YAML files in a repository's routines/ directory. */
@@ -10,8 +10,8 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
- import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
- import { safeJoin } from './paths.js';
13
+ import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
+ 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';
@@ -22,6 +22,14 @@ export const GITHUB_TRIGGER_EVENTS = [
22
22
  'issue_comment',
23
23
  'workflow_run',
24
24
  ];
25
+ /** Canonical set of accepted Linear trigger events — single source for validation. */
26
+ export const LINEAR_TRIGGER_EVENTS = [
27
+ 'Issue',
28
+ 'IssueLabel',
29
+ 'Comment',
30
+ 'Project',
31
+ 'Cycle',
32
+ ];
25
33
  /**
26
34
  * Map a user-facing `--on` alias to a canonical GitHub trigger event.
27
35
  * Accepts the canonical names plus friendly shortcuts (e.g. `pr`, `pr_opened`
@@ -79,10 +87,13 @@ const JOB_DEFAULTS = {
79
87
  enabled: true,
80
88
  };
81
89
  /**
82
- * List all job configs, scanning project > user routine dirs.
83
- * Project routines (`<project>/.agents/routines/`) shadow user routines of the
84
- * same name. Project discovery is opt-in via `cwd`; the daemon (which calls
85
- * `listJobs()` with no argument) only sees user routines.
90
+ * List all job configs, scanning project > user > system routine dirs.
91
+ * Higher layers shadow lower ones of the same name (first-seen wins): a project
92
+ * routine shadows a user routine, and a user routine shadows a built-in system
93
+ * routine (`~/.agents/.system/routines/`, shipped via gh:phnx-labs/.agents-system).
94
+ * Project discovery is opt-in via `cwd`; the daemon (which calls `listJobs()`
95
+ * with no argument) sees user + system routines, so a built-in routine fires for
96
+ * every install unless the user overrides or disables it by name.
86
97
  */
87
98
  export function listJobs(cwd) {
88
99
  ensureAgentsDir();
@@ -95,6 +106,7 @@ export function listJobs(cwd) {
95
106
  dirs.push(projectDir);
96
107
  }
97
108
  dirs.push(getRoutinesDir());
109
+ dirs.push(getSystemRoutinesDir());
98
110
  for (const dir of dirs) {
99
111
  if (!fs.existsSync(dir))
100
112
  continue;
@@ -112,9 +124,10 @@ export function listJobs(cwd) {
112
124
  return jobs;
113
125
  }
114
126
  /**
115
- * Read a single job config by name, checking project > user.
127
+ * Read a single job config by name, checking project > user > system.
116
128
  * Project discovery is opt-in via `cwd`; daemon callers pass no argument and
117
- * only resolve user routines.
129
+ * resolve user + system routines (a user routine of the same name shadows a
130
+ * built-in system routine).
118
131
  */
119
132
  export function readJob(name, cwd) {
120
133
  ensureAgentsDir();
@@ -125,6 +138,7 @@ export function readJob(name, cwd) {
125
138
  dirs.push(projectDir);
126
139
  }
127
140
  dirs.push(getRoutinesDir());
141
+ dirs.push(getSystemRoutinesDir());
128
142
  for (const dir of dirs) {
129
143
  for (const ext of ['.yml', '.yaml']) {
130
144
  const filePath = safeJoin(dir, name + ext);
@@ -260,6 +274,13 @@ export function validateJob(config) {
260
274
  if (!config.name || typeof config.name !== 'string') {
261
275
  errors.push('name is required');
262
276
  }
277
+ else if (!isSafeSegmentName(config.name)) {
278
+ // The name becomes a filesystem path segment (overlay HOME under the
279
+ // routines dir). Reject separators, '.'/'..', and null bytes so a synced
280
+ // config can't traverse out of ~/.agents/routines.
281
+ errors.push(`invalid name ${JSON.stringify(config.name)}: must be a single path segment ` +
282
+ `(no '/', '\\\\', or null bytes, and not '.' or '..')`);
283
+ }
263
284
  const hasSchedule = Boolean(config.schedule && typeof config.schedule === 'string');
264
285
  const hasTrigger = config.trigger !== undefined;
265
286
  if (!hasSchedule && !hasTrigger) {
@@ -284,11 +305,16 @@ export function validateJob(config) {
284
305
  }
285
306
  const hasAgent = Boolean(config.agent && typeof config.agent === 'string');
286
307
  const hasWorkflow = Boolean(config.workflow && typeof config.workflow === 'string');
287
- if (!hasAgent && !hasWorkflow) {
288
- errors.push('exactly one of agent or workflow is required');
308
+ const hasCommand = Boolean(config.command && typeof config.command === 'string');
309
+ const set = [hasAgent, hasWorkflow, hasCommand].filter(Boolean).length;
310
+ if (set === 0) {
311
+ errors.push('exactly one of agent, workflow, or command is required');
312
+ }
313
+ else if (set > 1) {
314
+ errors.push('exactly one of agent, workflow, or command may be set (not more)');
289
315
  }
290
- else if (hasAgent && hasWorkflow) {
291
- errors.push('exactly one of agent or workflow must be set (not both)');
316
+ if (config.command !== undefined && (typeof config.command !== 'string' || config.command.trim() === '')) {
317
+ errors.push('command must be a non-empty shell command string');
292
318
  }
293
319
  if (hasAgent && config.agent && !ALL_AGENT_IDS.includes(config.agent)) {
294
320
  errors.push(`agent must be one of: ${ALL_AGENT_IDS.join(', ')}`);
@@ -322,7 +348,8 @@ export function validateJob(config) {
322
348
  if (config.effort && !['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(config.effort)) {
323
349
  errors.push('effort must be low, medium, high, xhigh, max, or auto');
324
350
  }
325
- if (!config.prompt || typeof config.prompt !== 'string') {
351
+ // command routines run a plain shell and never build a prompt; agent/workflow routines require one.
352
+ if (!hasCommand && (!config.prompt || typeof config.prompt !== 'string')) {
326
353
  errors.push('prompt is required');
327
354
  }
328
355
  if (config.timeout && !parseTimeout(config.timeout)) {
@@ -358,17 +385,35 @@ export function validateTrigger(trigger) {
358
385
  return ['trigger must be an object'];
359
386
  }
360
387
  const t = trigger;
361
- if (t.type !== 'github_event') {
362
- errors.push("trigger.type must be 'github_event'");
388
+ if (t.type !== 'github_event' && t.type !== 'linear_event') {
389
+ errors.push("trigger.type must be 'github_event' or 'linear_event'");
390
+ return errors;
391
+ }
392
+ if (t.type === 'github_event') {
393
+ const github = t;
394
+ if (!github.event || !GITHUB_TRIGGER_EVENTS.includes(github.event)) {
395
+ errors.push(`trigger.event must be one of: ${GITHUB_TRIGGER_EVENTS.join(', ')}`);
396
+ }
397
+ if (github.repo !== undefined && (typeof github.repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(github.repo))) {
398
+ errors.push('trigger.repo must be in owner/name form');
399
+ }
400
+ if (github.branch !== undefined && typeof github.branch !== 'string') {
401
+ errors.push('trigger.branch must be a string');
402
+ }
403
+ return errors;
363
404
  }
364
- if (!t.event || !GITHUB_TRIGGER_EVENTS.includes(t.event)) {
365
- errors.push(`trigger.event must be one of: ${GITHUB_TRIGGER_EVENTS.join(', ')}`);
405
+ const linear = t;
406
+ if (!linear.event || !LINEAR_TRIGGER_EVENTS.includes(linear.event)) {
407
+ errors.push(`trigger.event must be one of: ${LINEAR_TRIGGER_EVENTS.join(', ')}`);
366
408
  }
367
- if (t.repo !== undefined && (typeof t.repo !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(t.repo))) {
368
- errors.push('trigger.repo must be in owner/name form');
409
+ if (linear.action !== undefined && typeof linear.action !== 'string') {
410
+ errors.push('trigger.action must be a string');
369
411
  }
370
- if (t.branch !== undefined && typeof t.branch !== 'string') {
371
- errors.push('trigger.branch must be a string');
412
+ if (linear.teamKey !== undefined && (typeof linear.teamKey !== 'string' || !/^[A-Z][A-Z0-9]*$/.test(linear.teamKey))) {
413
+ errors.push('trigger.teamKey must be an uppercase Linear team key');
414
+ }
415
+ if (linear.label !== undefined && typeof linear.label !== 'string') {
416
+ errors.push('trigger.label must be a string');
372
417
  }
373
418
  return errors;
374
419
  }
@@ -413,7 +458,7 @@ export function resolveJobPrompt(config) {
413
458
  // Last report (special handling)
414
459
  const latestRun = getLatestRun(config.name);
415
460
  if (latestRun) {
416
- const reportPath = path.join(getRunsDir(), config.name, latestRun.runId, 'report.md');
461
+ const reportPath = path.join(getJobRunsDir(config.name), latestRun.runId, 'report.md');
417
462
  if (fs.existsSync(reportPath)) {
418
463
  const report = fs.readFileSync(reportPath, 'utf-8');
419
464
  prompt = prompt.replace(/\{last_report\}/g, report);
@@ -449,8 +494,7 @@ export function parseTimeout(timeout) {
449
494
  }
450
495
  /** List all run metadata entries for a job, sorted chronologically. */
451
496
  export function listRuns(jobName) {
452
- const runsDir = getRunsDir();
453
- const jobRunsDir = path.join(runsDir, jobName);
497
+ const jobRunsDir = getJobRunsDir(jobName);
454
498
  if (!fs.existsSync(jobRunsDir))
455
499
  return [];
456
500
  const entries = fs.readdirSync(jobRunsDir, { withFileTypes: true })
@@ -473,13 +517,13 @@ export function getLatestRun(jobName) {
473
517
  /** Persist run metadata to its run directory as meta.json. */
474
518
  export function writeRunMeta(meta) {
475
519
  ensureAgentsDir();
476
- const runDir = path.join(getRunsDir(), meta.jobName, meta.runId);
520
+ const runDir = path.join(getJobRunsDir(meta.jobName), meta.runId);
477
521
  fs.mkdirSync(runDir, { recursive: true });
478
522
  fs.writeFileSync(path.join(runDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
479
523
  }
480
524
  /** Read run metadata from disk. Returns null if missing or corrupt. */
481
525
  export function readRunMeta(jobName, runId) {
482
- const metaPath = path.join(getRunsDir(), jobName, runId, 'meta.json');
526
+ const metaPath = path.join(getJobRunsDir(jobName), runId, 'meta.json');
483
527
  if (!fs.existsSync(metaPath))
484
528
  return null;
485
529
  try {
@@ -489,9 +533,20 @@ export function readRunMeta(jobName, runId) {
489
533
  return null;
490
534
  }
491
535
  }
536
+ /**
537
+ * Runs directory for a single job, with the (untrusted) job name contained to a
538
+ * single segment beneath the runs dir — same guard as `getJobHomePath`. The name
539
+ * comes from routine YAML and can arrive via a synced config repo; every runs-dir
540
+ * sink (run dir, meta read/write, last-report read) routes through here so a
541
+ * crafted `name` like `../../../../tmp/x` can't `mkdirSync`/write `stdout.log`,
542
+ * `meta.json`, or `report.md` outside `~/.agents/.history/runs`.
543
+ */
544
+ export function getJobRunsDir(jobName) {
545
+ return safeJoin(getRunsDir(), jobName);
546
+ }
492
547
  /** Get the filesystem path for a specific run's directory. */
493
548
  export function getRunDir(jobName, runId) {
494
- return path.join(getRunsDir(), jobName, runId);
549
+ return path.join(getJobRunsDir(jobName), runId);
495
550
  }
496
551
  /** Discover routine YAML files in a repository's routines/ directory. */
497
552
  export function discoverJobsFromRepo(repoPath) {