@phnx-labs/agents-cli 1.20.63 → 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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. package/package.json +1 -1
@@ -11,7 +11,7 @@ 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 { safeJoin } from './paths.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';
@@ -274,6 +274,13 @@ export function validateJob(config) {
274
274
  if (!config.name || typeof config.name !== 'string') {
275
275
  errors.push('name is required');
276
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
+ }
277
284
  const hasSchedule = Boolean(config.schedule && typeof config.schedule === 'string');
278
285
  const hasTrigger = config.trigger !== undefined;
279
286
  if (!hasSchedule && !hasTrigger) {
@@ -298,11 +305,16 @@ export function validateJob(config) {
298
305
  }
299
306
  const hasAgent = Boolean(config.agent && typeof config.agent === 'string');
300
307
  const hasWorkflow = Boolean(config.workflow && typeof config.workflow === 'string');
301
- if (!hasAgent && !hasWorkflow) {
302
- 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)');
303
315
  }
304
- else if (hasAgent && hasWorkflow) {
305
- 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');
306
318
  }
307
319
  if (hasAgent && config.agent && !ALL_AGENT_IDS.includes(config.agent)) {
308
320
  errors.push(`agent must be one of: ${ALL_AGENT_IDS.join(', ')}`);
@@ -336,7 +348,8 @@ export function validateJob(config) {
336
348
  if (config.effort && !['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(config.effort)) {
337
349
  errors.push('effort must be low, medium, high, xhigh, max, or auto');
338
350
  }
339
- 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')) {
340
353
  errors.push('prompt is required');
341
354
  }
342
355
  if (config.timeout && !parseTimeout(config.timeout)) {
@@ -445,7 +458,7 @@ export function resolveJobPrompt(config) {
445
458
  // Last report (special handling)
446
459
  const latestRun = getLatestRun(config.name);
447
460
  if (latestRun) {
448
- const reportPath = path.join(getRunsDir(), config.name, latestRun.runId, 'report.md');
461
+ const reportPath = path.join(getJobRunsDir(config.name), latestRun.runId, 'report.md');
449
462
  if (fs.existsSync(reportPath)) {
450
463
  const report = fs.readFileSync(reportPath, 'utf-8');
451
464
  prompt = prompt.replace(/\{last_report\}/g, report);
@@ -481,8 +494,7 @@ export function parseTimeout(timeout) {
481
494
  }
482
495
  /** List all run metadata entries for a job, sorted chronologically. */
483
496
  export function listRuns(jobName) {
484
- const runsDir = getRunsDir();
485
- const jobRunsDir = path.join(runsDir, jobName);
497
+ const jobRunsDir = getJobRunsDir(jobName);
486
498
  if (!fs.existsSync(jobRunsDir))
487
499
  return [];
488
500
  const entries = fs.readdirSync(jobRunsDir, { withFileTypes: true })
@@ -505,13 +517,13 @@ export function getLatestRun(jobName) {
505
517
  /** Persist run metadata to its run directory as meta.json. */
506
518
  export function writeRunMeta(meta) {
507
519
  ensureAgentsDir();
508
- const runDir = path.join(getRunsDir(), meta.jobName, meta.runId);
520
+ const runDir = path.join(getJobRunsDir(meta.jobName), meta.runId);
509
521
  fs.mkdirSync(runDir, { recursive: true });
510
522
  fs.writeFileSync(path.join(runDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
511
523
  }
512
524
  /** Read run metadata from disk. Returns null if missing or corrupt. */
513
525
  export function readRunMeta(jobName, runId) {
514
- const metaPath = path.join(getRunsDir(), jobName, runId, 'meta.json');
526
+ const metaPath = path.join(getJobRunsDir(jobName), runId, 'meta.json');
515
527
  if (!fs.existsSync(metaPath))
516
528
  return null;
517
529
  try {
@@ -521,9 +533,20 @@ export function readRunMeta(jobName, runId) {
521
533
  return null;
522
534
  }
523
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
+ }
524
547
  /** Get the filesystem path for a specific run's directory. */
525
548
  export function getRunDir(jobName, runId) {
526
- return path.join(getRunsDir(), jobName, runId);
549
+ return path.join(getJobRunsDir(jobName), runId);
527
550
  }
528
551
  /** Discover routine YAML files in a repository's routines/ directory. */
529
552
  export function discoverJobsFromRepo(repoPath) {
@@ -44,17 +44,20 @@ export function buildJobCommand(config, resolvedPrompt) {
44
44
  const cmd = ['agents', 'run', config.workflow, resolvedPrompt, '--mode', config.mode];
45
45
  return cmd;
46
46
  }
47
+ // Past the workflow branch this is an agent (or resume) job — command jobs never
48
+ // reach buildJobCommand (execute*Job branches out first), and validateJob guarantees agent.
49
+ const agent = config.agent;
47
50
  // Resume branch: reopen an EXISTING session via `agents run <agent> --resume <id>`
48
51
  // instead of starting fresh. The real session resumes with its full prior context
49
52
  // (index-based lookup, cwd-independent) and `resolvedPrompt` becomes its next turn —
50
53
  // so a self-scheduled wake (e.g. /hibernate) is handled by the session that scheduled
51
54
  // it, not a fresh, context-less agent that would refuse an "opaque" instruction.
52
55
  if (config.resume) {
53
- return ['agents', 'run', config.agent, '--resume', config.resume, resolvedPrompt, '--mode', config.mode];
56
+ return ['agents', 'run', agent, '--resume', config.resume, resolvedPrompt, '--mode', config.mode];
54
57
  }
55
- const template = AGENT_COMMANDS[config.agent];
58
+ const template = AGENT_COMMANDS[agent];
56
59
  if (!template) {
57
- throw new Error(`Unsupported agent for daemon jobs: ${config.agent}`);
60
+ throw new Error(`Unsupported agent for daemon jobs: ${agent}`);
58
61
  }
59
62
  let cmd = template.map((part) => part.replace('{prompt}', resolvedPrompt));
60
63
  // Canonicalize mode (accepts legacy `full` as alias for `skip`).
@@ -155,10 +158,12 @@ export function buildJobCommand(config, resolvedPrompt) {
155
158
  * Reasoning level (config.config.reasoning) maps to per-agent flags via models.ts.
156
159
  */
157
160
  function appendModelAndReasoning(cmd, config) {
161
+ // Only called from buildJobCommand's agent path — config.agent is set.
162
+ const agent = config.agent;
158
163
  const model = config.config?.model;
159
164
  if (model) {
160
165
  if (config.version) {
161
- const resolved = resolveModel(config.agent, config.version, model);
166
+ const resolved = resolveModel(agent, config.version, model);
162
167
  if (resolved.warning) {
163
168
  process.stderr.write(`[agents] ${resolved.warning}\n`);
164
169
  }
@@ -170,7 +175,7 @@ function appendModelAndReasoning(cmd, config) {
170
175
  }
171
176
  const reasoning = config.config?.reasoning;
172
177
  if (reasoning) {
173
- const flags = buildReasoningFlags(config.agent, reasoning);
178
+ const flags = buildReasoningFlags(agent, reasoning);
174
179
  if (flags.length > 0)
175
180
  cmd.push(...flags);
176
181
  }
@@ -178,6 +183,48 @@ function appendModelAndReasoning(cmd, config) {
178
183
  function generateRunId() {
179
184
  return new Date().toISOString().replace(/[:.]/g, '-');
180
185
  }
186
+ /**
187
+ * Build the argv for a command-mode routine: run the shell string directly
188
+ * through the platform shell. No agent binary, no rotation, no sandbox.
189
+ */
190
+ function buildShellCommand(command) {
191
+ return process.platform === 'win32'
192
+ ? ['cmd', '/c', command]
193
+ : ['/bin/sh', '-c', command];
194
+ }
195
+ /**
196
+ * Real (un-sandboxed) environment for a command routine. Command routines do
197
+ * `npm i -g` / `git pull` and need the actual $HOME / $PATH, not the sandbox
198
+ * overlay. Only TZ is injected when the routine pins a timezone.
199
+ */
200
+ function commandSpawnEnv(config) {
201
+ const env = { ...process.env };
202
+ if (config.timezone)
203
+ env.TZ = config.timezone;
204
+ return env;
205
+ }
206
+ /** POSIX single-quote a string so it is safe to embed in a `/bin/sh -c` script. */
207
+ function shSingleQuote(s) {
208
+ return `'${s.replace(/'/g, `'\\''`)}'`;
209
+ }
210
+ /**
211
+ * Detached command routines write their own exit code to `<runDir>/exit-code`
212
+ * (see the wrapper in executeCommandJobDetached). `monitorRunningJobs` reads it
213
+ * to recover the true terminal status when the daemon restarted between spawn and
214
+ * exit and so missed the in-process `child.on('exit')`. Returns null when the
215
+ * file is absent/unparseable (child killed or crashed before writing it).
216
+ */
217
+ function readCommandExitCode(runDir) {
218
+ try {
219
+ const raw = fs.readFileSync(path.join(runDir, 'exit-code'), 'utf-8').trim();
220
+ if (!/^-?\d+$/.test(raw))
221
+ return null;
222
+ return parseInt(raw, 10);
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ }
181
228
  /**
182
229
  * Resolve the version/account chain for a routine the same way `agents run`
183
230
  * does: honor an explicit `version:` pin; otherwise use the configured run
@@ -191,6 +238,8 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
191
238
  if (config.workflow) {
192
239
  return { chain: [], rotation: null, pinned: false };
193
240
  }
241
+ // resolveRoutineLaunch is only called for agent jobs (workflow returns above;
242
+ // command jobs branch out of execute*Job before reaching this).
194
243
  const agent = config.agent;
195
244
  if (config.version) {
196
245
  const version = config.version;
@@ -399,6 +448,12 @@ export async function executeJob(config, deps) {
399
448
  if (eligibility) {
400
449
  throw new Error(eligibility.message);
401
450
  }
451
+ // Command-mode: run a plain shell command directly (no agent, no rotation,
452
+ // no pinning, no sandbox overlay). Reuses the run-record machinery so
453
+ // list/runs/overdue keep working.
454
+ if (config.command) {
455
+ return executeCommandJobForeground(config);
456
+ }
402
457
  maybeRotate();
403
458
  const launch = await resolveRoutineLaunch(config);
404
459
  const primaryVersion = launch.chain[0]?.version ?? config.version;
@@ -425,6 +480,7 @@ export async function executeJob(config, deps) {
425
480
  : { ...process.env };
426
481
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
427
482
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
483
+ // (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
428
484
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
429
485
  const meta = {
430
486
  jobName: config.name,
@@ -561,6 +617,100 @@ export async function executeJob(config, deps) {
561
617
  timer.end({ status: 'failed', exitCode: 1, runId });
562
618
  return { meta, reportPath: null };
563
619
  }
620
+ /**
621
+ * Foreground execution for a command-mode routine (`config.command`). Runs a
622
+ * plain shell command in the REAL environment (no sandbox), captures stdout+
623
+ * stderr to `stdout.log`, awaits completion, and honors `config.timeout` with
624
+ * the same SIGTERM→SIGKILL kill mechanism the agent path uses. No agent is
625
+ * spawned; the run record carries `command` instead of `agent`. There is no
626
+ * agent report to extract, so `reportPath` is always null (the stdout log is
627
+ * the artifact — same location the agent path writes).
628
+ */
629
+ async function executeCommandJobForeground(config) {
630
+ const timer = createTimer('agent.run', {
631
+ jobName: config.name,
632
+ mode: config.mode,
633
+ schedule: config.schedule,
634
+ });
635
+ const runId = generateRunId();
636
+ const runDir = getRunDir(config.name, runId);
637
+ fs.mkdirSync(runDir, { recursive: true });
638
+ const stdoutPath = path.join(runDir, 'stdout.log');
639
+ const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
640
+ const meta = {
641
+ jobName: config.name,
642
+ runId,
643
+ command: config.command,
644
+ pid: null,
645
+ spawnedAt: Date.now(),
646
+ status: 'running',
647
+ startedAt: new Date().toISOString(),
648
+ completedAt: null,
649
+ exitCode: null,
650
+ };
651
+ writeRunMeta(meta);
652
+ const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
653
+ const cmd = buildShellCommand(config.command);
654
+ const env = commandSpawnEnv(config);
655
+ process.stderr.write(`[agents] routine ${config.name}: running command\n`);
656
+ const result = await new Promise((resolve) => {
657
+ const child = spawn(cmd[0], cmd.slice(1), {
658
+ stdio: ['ignore', stdoutFd, stdoutFd],
659
+ ...backgroundSpawnOptions({ fdStdio: true }),
660
+ env,
661
+ });
662
+ meta.pid = child.pid || null;
663
+ writeRunMeta(meta);
664
+ let settled = false;
665
+ const finish = (r) => {
666
+ if (settled)
667
+ return;
668
+ settled = true;
669
+ try {
670
+ fs.closeSync(stdoutFd);
671
+ }
672
+ catch { /* fd already closed */ }
673
+ resolve(r);
674
+ };
675
+ const timeoutTimer = setTimeout(() => {
676
+ try {
677
+ if (child.pid)
678
+ process.kill(-child.pid, 'SIGTERM');
679
+ }
680
+ catch { /* process already exited */ }
681
+ setTimeout(() => {
682
+ try {
683
+ if (child.pid)
684
+ process.kill(-child.pid, 'SIGKILL');
685
+ }
686
+ catch { /* process already exited */ }
687
+ }, 5000);
688
+ finish({ exitCode: null, status: 'timeout' });
689
+ }, timeoutMs);
690
+ child.on('exit', (code) => {
691
+ clearTimeout(timeoutTimer);
692
+ finish({ exitCode: code, status: code === 0 ? 'completed' : 'failed' });
693
+ });
694
+ child.on('error', (err) => {
695
+ clearTimeout(timeoutTimer);
696
+ finish({ exitCode: 1, status: 'failed', error: err.message });
697
+ });
698
+ });
699
+ meta.status = result.status;
700
+ meta.exitCode = result.exitCode ?? (result.status === 'completed' ? 0 : 1);
701
+ meta.completedAt = new Date().toISOString();
702
+ writeRunMeta(meta);
703
+ if (result.error) {
704
+ process.stderr.write(`[agents] routine ${config.name}: command spawn failed: ${result.error}\n`);
705
+ }
706
+ timer.end({
707
+ status: meta.status,
708
+ exitCode: meta.exitCode ?? undefined,
709
+ runId,
710
+ ...(result.error ? { error: result.error } : {}),
711
+ });
712
+ return { meta, reportPath: null };
713
+ }
564
714
  /** Spawn a job as a detached process and return immediately with run metadata. */
565
715
  export async function executeJobDetached(config) {
566
716
  const eligibility = checkJobDeviceEligibility(config);
@@ -568,6 +718,12 @@ export async function executeJobDetached(config) {
568
718
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
569
719
  throw new Error(eligibility.message);
570
720
  }
721
+ // Command-mode: fire a plain shell command detached (no agent, no rotation,
722
+ // no pinning, no sandbox overlay). Still writes a run record so the daemon,
723
+ // list/runs, and overdue tracking keep working.
724
+ if (config.command) {
725
+ return executeCommandJobDetached(config);
726
+ }
571
727
  // Pre-flight: pick a healthy version/account so the daemon does not launch
572
728
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
573
729
  // wait); the next schedule tick re-selects if this attempt still fails.
@@ -577,7 +733,7 @@ export async function executeJobDetached(config) {
577
733
  let cmd = buildJobCommand(config, resolvedPrompt);
578
734
  // workflow AND resume dispatch through `agents run` — never binary-pin them (pinning
579
735
  // rewrites cmd[0] to the agent binary → broken `<binary> run …`).
580
- if (!dispatchesViaAgentsRun(config) && version) {
736
+ if (!dispatchesViaAgentsRun(config) && version && config.agent) {
581
737
  cmd = pinJobBinary(cmd, config.agent, version);
582
738
  }
583
739
  // Resume must run against the REAL home: `--resume <id>` resolves the session from
@@ -601,6 +757,7 @@ export async function executeJobDetached(config) {
601
757
  e.TZ = config.timezone;
602
758
  return e;
603
759
  })()
760
+ // Non-command path only: config.agent is always set here (command/workflow branch earlier).
604
761
  : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone);
605
762
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
606
763
  const meta = {
@@ -640,6 +797,74 @@ export async function executeJobDetached(config) {
640
797
  writeRunMeta(meta);
641
798
  return meta;
642
799
  }
800
+ /**
801
+ * Detached (fire-and-forget) execution for a command-mode routine. Mirrors the
802
+ * agent detached flow: write an initial running record, spawn the shell command
803
+ * un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
804
+ * `monitorRunningJobs` reaps the record on the next tick.
805
+ */
806
+ function executeCommandJobDetached(config) {
807
+ const runId = generateRunId();
808
+ const runDir = getRunDir(config.name, runId);
809
+ fs.mkdirSync(runDir, { recursive: true });
810
+ const stdoutPath = path.join(runDir, 'stdout.log');
811
+ const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
812
+ // Wrap the shell so the child records its own exit code to <runDir>/exit-code.
813
+ // The in-process `child.on('exit')` below writes the terminal record while the
814
+ // daemon is alive (the common case); the file lets monitorRunningJobs recover
815
+ // the real status if the daemon restarted between spawn and exit. (win32 relies
816
+ // on the exit event only.)
817
+ const exitCodePath = path.join(runDir, 'exit-code');
818
+ // Run the command in a SUBSHELL `( … )` so that if it calls `exit`, only the
819
+ // subshell exits — the outer shell still captures `$?` and writes the file.
820
+ const cmd = process.platform === 'win32'
821
+ ? buildShellCommand(config.command)
822
+ : ['/bin/sh', '-c',
823
+ `(\n${config.command}\n)\n__ac_rc=$?; printf '%s' "$__ac_rc" > ${shSingleQuote(exitCodePath)} 2>/dev/null; exit $__ac_rc`];
824
+ const env = commandSpawnEnv(config);
825
+ const meta = {
826
+ jobName: config.name,
827
+ runId,
828
+ command: config.command,
829
+ pid: null,
830
+ spawnedAt: Date.now(),
831
+ status: 'running',
832
+ startedAt: new Date().toISOString(),
833
+ completedAt: null,
834
+ exitCode: null,
835
+ };
836
+ const child = spawn(cmd[0], cmd.slice(1), {
837
+ stdio: ['ignore', stdoutFd, stdoutFd],
838
+ ...backgroundSpawnOptions({ fdStdio: true }),
839
+ env,
840
+ });
841
+ // Record the real terminal status ourselves — the daemon stays alive after this
842
+ // fire-and-forget call, so the exit event fires here. (monitorRunningJobs no
843
+ // longer force-fails command jobs; it reads exit-code only on the restart edge.)
844
+ let settled = false;
845
+ const settle = (status, exitCode) => {
846
+ if (settled)
847
+ return;
848
+ settled = true;
849
+ meta.status = status;
850
+ meta.exitCode = exitCode;
851
+ meta.completedAt = new Date().toISOString();
852
+ writeRunMeta(meta);
853
+ };
854
+ child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
855
+ child.on('error', (err) => {
856
+ settle('failed', 1);
857
+ process.stderr.write(`[agents] daemon: command spawn failed for job "${config.name}": ${err.message}\n`);
858
+ });
859
+ child.unref();
860
+ try {
861
+ fs.closeSync(stdoutFd);
862
+ }
863
+ catch { /* fd already closed */ }
864
+ meta.pid = child.pid || null;
865
+ writeRunMeta(meta);
866
+ return meta;
867
+ }
643
868
  function extractAndSaveReport(stdoutPath, agentType, runDir) {
644
869
  try {
645
870
  const report = extractReport(stdoutPath, agentType);
@@ -792,26 +1017,43 @@ export function monitorRunningJobs() {
792
1017
  continue;
793
1018
  const runDirPath = path.join(jobRunsPath, runDirEntry.name);
794
1019
  const stdoutPath = path.join(runDirPath, 'stdout.log');
1020
+ // Command-mode records carry no agent; there is no stream-json report to
1021
+ // parse or extract. Reap them on pid liveness alone.
1022
+ const isCommandRun = Boolean(meta.command) || !meta.agent;
795
1023
  const wallClockMs = Date.now() - Date.parse(meta.startedAt);
796
1024
  if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
797
1025
  meta.status = 'timeout';
798
1026
  meta.completedAt = new Date().toISOString();
799
1027
  writeRunMeta(meta);
800
- extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
1028
+ if (!isCommandRun)
1029
+ extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
801
1030
  continue;
802
1031
  }
803
1032
  if (!isPidOurs(meta.pid, meta.spawnedAt)) {
804
- const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
805
- if (inferred) {
806
- meta.status = inferred.status;
807
- meta.exitCode = inferred.exitCode;
1033
+ if (isCommandRun) {
1034
+ // Command routines normally record their own terminal status via
1035
+ // child.on('exit') (so this record would already be non-'running' and
1036
+ // skipped above). Reaching here means the daemon restarted mid-run and
1037
+ // missed the exit event — recover the true code from the exit-code file
1038
+ // the child wrote; its absence means the child was killed/crashed.
1039
+ const ec = readCommandExitCode(runDirPath);
1040
+ meta.status = ec === 0 ? 'completed' : 'failed';
1041
+ meta.exitCode = ec;
808
1042
  }
809
1043
  else {
810
- meta.status = 'failed';
1044
+ const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
1045
+ if (inferred) {
1046
+ meta.status = inferred.status;
1047
+ meta.exitCode = inferred.exitCode;
1048
+ }
1049
+ else {
1050
+ meta.status = 'failed';
1051
+ }
811
1052
  }
812
1053
  meta.completedAt = new Date().toISOString();
813
1054
  writeRunMeta(meta);
814
- extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
1055
+ if (!isCommandRun)
1056
+ extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
815
1057
  }
816
1058
  }
817
1059
  catch { /* corrupt or unreadable meta.json */ }
@@ -9,7 +9,15 @@
9
9
  import type { JobConfig } from './routines.js';
10
10
  /** Build a restricted environment for a sandboxed process, setting HOME to the overlay. */
11
11
  export declare function buildSpawnEnv(overlayHome: string, extraEnv?: Record<string, string>): Record<string, string>;
12
- /** Get the overlay HOME directory path for a named job. */
12
+ /**
13
+ * Get the overlay HOME directory path for a named job.
14
+ *
15
+ * The job name originates from routine YAML (`name:` field or file basename),
16
+ * which can arrive from a synced user/system config repo. `safeJoin` contains
17
+ * it to a single segment beneath the routines dir so a crafted name such as
18
+ * `../../../..` cannot steer `prepareJobHome`/`cleanJobHome` (which does a
19
+ * recursive `rmSync`) at a path outside `~/.agents/routines`.
20
+ */
13
21
  export declare function getJobHomePath(name: string): string;
14
22
  /** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
15
23
  export declare function prepareJobHome(config: JobConfig): string;
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
13
  import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
+ import { safeJoin } from './paths.js';
14
15
  import { createLink } from './platform/index.js';
15
16
  function resolveRealHome() {
16
17
  const home = os.homedir();
@@ -79,9 +80,17 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
79
80
  }
80
81
  return env;
81
82
  }
82
- /** Get the overlay HOME directory path for a named job. */
83
+ /**
84
+ * Get the overlay HOME directory path for a named job.
85
+ *
86
+ * The job name originates from routine YAML (`name:` field or file basename),
87
+ * which can arrive from a synced user/system config repo. `safeJoin` contains
88
+ * it to a single segment beneath the routines dir so a crafted name such as
89
+ * `../../../..` cannot steer `prepareJobHome`/`cleanJobHome` (which does a
90
+ * recursive `rmSync`) at a path outside `~/.agents/routines`.
91
+ */
83
92
  export function getJobHomePath(name) {
84
- return path.join(getRoutinesDir(), name, 'home');
93
+ return path.join(safeJoin(getRoutinesDir(), name), 'home');
85
94
  }
86
95
  /** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
87
96
  export function prepareJobHome(config) {