@phnx-labs/agents-cli 1.20.54 → 1.20.56

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.
@@ -44,6 +44,30 @@ export declare function mcpEntryToInstallSpec(entry: McpServerEntry): {
44
44
  } | null;
45
45
  /** Look up detailed info for an MCP server by exact name. */
46
46
  export declare function getMcpServerInfo(serverName: string, registryName?: string): Promise<McpServerEntry | null>;
47
+ /** One row of a skill index document. */
48
+ export interface SkillIndexEntry {
49
+ name: string;
50
+ description?: string;
51
+ source?: string;
52
+ identifier?: string;
53
+ trust_level?: string;
54
+ repo?: string;
55
+ path?: string;
56
+ tags?: string[];
57
+ author?: string;
58
+ installs?: number;
59
+ /** Lowercase hex sha256 of the skill's SKILL.md — written by `agents publish`. */
60
+ sha256?: string;
61
+ }
62
+ /** Raw shape of the skill index document served by Hermes and compatible registries. */
63
+ export interface SkillIndexDocument {
64
+ version?: number;
65
+ generated_at?: string;
66
+ skill_count?: number;
67
+ skills: SkillIndexEntry[];
68
+ }
69
+ /** Map a raw skill-index row into the canonical SkillEntry shape. */
70
+ export declare function normalizeSkillEntry(raw: SkillIndexEntry): SkillEntry;
47
71
  /** Search skill registries for entries matching a query string. */
48
72
  export declare function searchSkillRegistries(query: string, options?: {
49
73
  registry?: string;
@@ -66,3 +90,34 @@ export declare function parsePackageIdentifier(identifier: string): {
66
90
  };
67
91
  /** Resolve a package identifier to an installable package with source metadata. */
68
92
  export declare function resolvePackage(identifier: string): Promise<ResolvedPackage | null>;
93
+ /** Lowercase hex sha256 of a file's bytes. Small files only (SKILL.md). */
94
+ export declare function sha256OfFile(file: string): string;
95
+ /**
96
+ * Parse an 'owner/repo' slug from a git remote URL (https or scp-style ssh).
97
+ * Returns null if the URL is not a recognizable GitHub-style remote.
98
+ */
99
+ export declare function parseOwnerRepoFromRemote(remoteUrl: string): string | null;
100
+ /**
101
+ * Walk a repo's skills/ and build a flat {@link SkillIndexDocument}. Each entry
102
+ * carries the sha256 of its SKILL.md so install can verify integrity after
103
+ * cloning — this is the artifact `agents publish` commits + pushes.
104
+ *
105
+ * `repoSlug` is the 'owner/repo' the skills are published under, written into
106
+ * each entry's `repo` field so {@link skillEntryToGitSource} resolves it to
107
+ * `gh:owner/repo`. `identifier` is set to the skill's directory name so
108
+ * `agents install skill:<name>` resolves against this index.
109
+ */
110
+ export declare function buildSkillIndex(repoPath: string, repoSlug: string, opts?: {
111
+ generatedAt?: string;
112
+ }): SkillIndexDocument;
113
+ /**
114
+ * Verify a cloned skill's SKILL.md against the sha256 recorded in its registry
115
+ * entry. Returns ok when the entry carries no sha256 — indexes published before
116
+ * integrity hashes (or by third parties) simply skip the check. Returns an
117
+ * error when the file is missing or its hash differs, so install can abort
118
+ * rather than silently trusting a tampered artifact.
119
+ */
120
+ export declare function verifySkillIntegrity(repoPath: string, entry: Pick<SkillEntry, 'name' | 'path' | 'sha256'>): {
121
+ ok: boolean;
122
+ error?: string;
123
+ };
@@ -6,8 +6,11 @@
6
6
  * with transport, runtime, and argument metadata.
7
7
  */
8
8
  import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { createHash } from 'crypto';
9
11
  import { DEFAULT_REGISTRIES } from './types.js';
10
12
  import { readMeta, writeMeta } from './state.js';
13
+ import { discoverSkillsFromRepo } from './skills.js';
11
14
  const UNSAFE_PACKAGE_SPEC_CHARS = /[;&|`$\s\x00-\x1f\x7f]/;
12
15
  const NPM_SPEC_PATTERN = /^(@[a-z0-9][a-z0-9-_.]*\/)?[a-z0-9][a-z0-9-_.]*(@[A-Za-z0-9._+-]+)?$/;
13
16
  const PYPI_SPEC_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,_-]+\])?(==[A-Za-z0-9._-]+)?$/;
@@ -213,7 +216,7 @@ async function fetchSkillIndex(url, apiKey) {
213
216
  return doc;
214
217
  }
215
218
  /** Map a raw skill-index row into the canonical SkillEntry shape. */
216
- function normalizeSkillEntry(raw) {
219
+ export function normalizeSkillEntry(raw) {
217
220
  return {
218
221
  name: raw.name,
219
222
  description: raw.description,
@@ -225,6 +228,7 @@ function normalizeSkillEntry(raw) {
225
228
  installs: raw.installs,
226
229
  tags: raw.tags,
227
230
  trustLevel: raw.trust_level,
231
+ sha256: raw.sha256,
228
232
  };
229
233
  }
230
234
  /** Case-insensitive substring match against the fields users expect to search. */
@@ -418,3 +422,80 @@ export async function resolvePackage(identifier) {
418
422
  }
419
423
  return null;
420
424
  }
425
+ // ============================================================================
426
+ // PUBLISH — generate a self-hosted skill index + verify integrity on install
427
+ // ============================================================================
428
+ /** Lowercase hex sha256 of a file's bytes. Small files only (SKILL.md). */
429
+ export function sha256OfFile(file) {
430
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
431
+ }
432
+ /**
433
+ * Parse an 'owner/repo' slug from a git remote URL (https or scp-style ssh).
434
+ * Returns null if the URL is not a recognizable GitHub-style remote.
435
+ */
436
+ export function parseOwnerRepoFromRemote(remoteUrl) {
437
+ const s = remoteUrl.trim().replace(/\.git$/, '');
438
+ // https://github.com/owner/repo or git@github.com:owner/repo
439
+ const m = s.match(/github\.com[/:]([^/]+\/[^/]+)$/);
440
+ return m ? m[1] : null;
441
+ }
442
+ /**
443
+ * Walk a repo's skills/ and build a flat {@link SkillIndexDocument}. Each entry
444
+ * carries the sha256 of its SKILL.md so install can verify integrity after
445
+ * cloning — this is the artifact `agents publish` commits + pushes.
446
+ *
447
+ * `repoSlug` is the 'owner/repo' the skills are published under, written into
448
+ * each entry's `repo` field so {@link skillEntryToGitSource} resolves it to
449
+ * `gh:owner/repo`. `identifier` is set to the skill's directory name so
450
+ * `agents install skill:<name>` resolves against this index.
451
+ */
452
+ export function buildSkillIndex(repoPath, repoSlug, opts) {
453
+ const discovered = discoverSkillsFromRepo(repoPath);
454
+ const skills = discovered.map((s) => ({
455
+ name: s.name,
456
+ description: s.metadata.description || undefined,
457
+ identifier: s.name,
458
+ source: repoSlug,
459
+ repo: repoSlug,
460
+ path: path.relative(repoPath, s.path),
461
+ author: s.metadata.author,
462
+ sha256: sha256OfFile(path.join(s.path, 'SKILL.md')),
463
+ }));
464
+ return {
465
+ version: 1,
466
+ generated_at: opts?.generatedAt,
467
+ skill_count: skills.length,
468
+ skills,
469
+ };
470
+ }
471
+ /**
472
+ * Verify a cloned skill's SKILL.md against the sha256 recorded in its registry
473
+ * entry. Returns ok when the entry carries no sha256 — indexes published before
474
+ * integrity hashes (or by third parties) simply skip the check. Returns an
475
+ * error when the file is missing or its hash differs, so install can abort
476
+ * rather than silently trusting a tampered artifact.
477
+ */
478
+ export function verifySkillIntegrity(repoPath, entry) {
479
+ if (!entry.sha256)
480
+ return { ok: true };
481
+ const rel = entry.path || path.join('skills', entry.name);
482
+ const skillMd = rel.endsWith('SKILL.md')
483
+ ? path.join(repoPath, rel)
484
+ : path.join(repoPath, rel, 'SKILL.md');
485
+ if (!fs.existsSync(skillMd)) {
486
+ return {
487
+ ok: false,
488
+ error: `Integrity check failed for skill '${entry.name}': SKILL.md not found at ${rel}.`,
489
+ };
490
+ }
491
+ const actual = sha256OfFile(skillMd);
492
+ const expected = entry.sha256.toLowerCase();
493
+ if (actual !== expected) {
494
+ return {
495
+ ok: false,
496
+ error: `Integrity check failed for skill '${entry.name}': expected sha256 ${expected}, got ${actual}. ` +
497
+ `The published SKILL.md does not match the registry index — refusing to install.`,
498
+ };
499
+ }
500
+ return { ok: true };
501
+ }
@@ -60,6 +60,14 @@ export interface JobConfig {
60
60
  prompt: string;
61
61
  timezone?: string;
62
62
  repo?: string;
63
+ /**
64
+ * Fleet allowlist — restrict this routine to specific devices. When omitted
65
+ * or empty, the routine is unrestricted and fires on every device running the
66
+ * scheduler. When set, only devices whose `machineId()` matches any entry
67
+ * (via `normalizeHost`) schedule, fire, catch up, or count this job as
68
+ * overdue; everywhere else it is inert and `run` refuses with a pointer.
69
+ */
70
+ devices?: string[];
63
71
  variables?: Record<string, string>;
64
72
  sandbox?: boolean;
65
73
  allow?: JobAllowConfig;
@@ -77,11 +85,39 @@ export interface RunMeta {
77
85
  agent: AgentId;
78
86
  workflow?: string;
79
87
  pid: number | null;
88
+ /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
89
+ spawnedAt?: number;
80
90
  status: 'running' | 'completed' | 'failed' | 'timeout';
81
91
  startedAt: string;
82
92
  completedAt: string | null;
83
93
  exitCode: number | null;
84
94
  }
95
+ /**
96
+ * True when the job may execute on this machine: no `devices` allowlist (or
97
+ * empty), or the allowlist includes this device. Both sides go through
98
+ * `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
99
+ * `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
100
+ * catchup/overdue, manual run) gates on this.
101
+ */
102
+ export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
103
+ /** Human presentation of a device-affinity mismatch for commands and runner. */
104
+ export interface JobEligibilityResult {
105
+ /** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
106
+ message: string;
107
+ /** One-line copy-paste suggestion, e.g. "agents routines run NAME --host a". */
108
+ suggestion: string;
109
+ /** Comma-separated allowed devices label, e.g. "a, b". */
110
+ allowedLabel: string;
111
+ /** First allowed device (normalized), useful for the suggested host. */
112
+ firstHost: string;
113
+ }
114
+ /**
115
+ * Return null when the job may run here; otherwise return a structured,
116
+ * human-friendly eligibility failure. Centralizes the message/suggestion
117
+ * construction so manual run, executeJob, and executeJobDetached stay in
118
+ * sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
119
+ */
120
+ export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
85
121
  /**
86
122
  * List all job configs, scanning project > user routine dirs.
87
123
  * Project routines (`<project>/.agents/routines/`) shadow user routines of the
@@ -95,7 +131,12 @@ export declare function listJobs(cwd?: string): JobConfig[];
95
131
  * only resolve user routines.
96
132
  */
97
133
  export declare function readJob(name: string, cwd?: string): JobConfig | null;
98
- /** Write a job config to disk, omitting fields that match defaults. */
134
+ /** Write a job config to disk, omitting fields that match defaults.
135
+ *
136
+ * Updates the one existing supported extension (.yml or .yaml) atomically.
137
+ * New routines are written as .yml. If both extensions exist for the same
138
+ * name, the write fails explicitly so we never choose or drop a sibling.
139
+ */
99
140
  export declare function writeJob(config: JobConfig): void;
100
141
  /** Delete a job config file by name. Returns true if the file existed. */
101
142
  export declare function deleteJob(name: string): boolean;
@@ -12,7 +12,9 @@ import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin } from './paths.js';
15
+ import { atomicWriteFileSync } from './fs-atomic.js';
15
16
  import { ALL_AGENT_IDS } from './agents.js';
17
+ import { machineId, normalizeHost } from './machine-id.js';
16
18
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
17
19
  export const GITHUB_TRIGGER_EVENTS = [
18
20
  'pull_request',
@@ -40,9 +42,38 @@ export function normalizeTriggerEvent(input) {
40
42
  };
41
43
  return aliases[key] ?? null;
42
44
  }
45
+ /**
46
+ * True when the job may execute on this machine: no `devices` allowlist (or
47
+ * empty), or the allowlist includes this device. Both sides go through
48
+ * `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
49
+ * `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
50
+ * catchup/overdue, manual run) gates on this.
51
+ */
52
+ export function jobRunsOnThisDevice(config) {
53
+ if (!config.devices || config.devices.length === 0)
54
+ return true;
55
+ const self = machineId();
56
+ return config.devices.some((d) => normalizeHost(d) === self);
57
+ }
58
+ /**
59
+ * Return null when the job may run here; otherwise return a structured,
60
+ * human-friendly eligibility failure. Centralizes the message/suggestion
61
+ * construction so manual run, executeJob, and executeJobDetached stay in
62
+ * sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
63
+ */
64
+ export function checkJobDeviceEligibility(config) {
65
+ if (jobRunsOnThisDevice(config))
66
+ return null;
67
+ const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
68
+ const allowedLabel = allowed.join(', ');
69
+ const firstHost = allowed[0] ?? 'HOST';
70
+ const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
71
+ const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
72
+ return { message, suggestion, allowedLabel, firstHost };
73
+ }
43
74
  /** Default values applied to every job config when fields are omitted. */
44
75
  const JOB_DEFAULTS = {
45
- mode: 'plan',
76
+ mode: 'auto',
46
77
  effort: 'auto',
47
78
  timeout: '10m',
48
79
  enabled: true,
@@ -110,6 +141,11 @@ function readJobFile(filePath) {
110
141
  const parsed = yaml.parse(content);
111
142
  if (!parsed || typeof parsed !== 'object')
112
143
  return null;
144
+ // Fail closed on the legacy singular `device` key. A routine that still
145
+ // carries it after v12 startup migration is unmigrated state and must be
146
+ // treated as unavailable/inert rather than unrestricted.
147
+ if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
148
+ return null;
113
149
  return {
114
150
  ...JOB_DEFAULTS,
115
151
  ...parsed,
@@ -120,13 +156,25 @@ function readJobFile(filePath) {
120
156
  return null;
121
157
  }
122
158
  }
123
- /** Write a job config to disk, omitting fields that match defaults. */
159
+ /** Write a job config to disk, omitting fields that match defaults.
160
+ *
161
+ * Updates the one existing supported extension (.yml or .yaml) atomically.
162
+ * New routines are written as .yml. If both extensions exist for the same
163
+ * name, the write fails explicitly so we never choose or drop a sibling.
164
+ */
124
165
  export function writeJob(config) {
125
166
  ensureAgentsDir();
126
167
  const jobsDir = getRoutinesDir();
127
- const filePath = safeJoin(jobsDir, config.name + '.yml');
168
+ const ymlPath = safeJoin(jobsDir, config.name + '.yml');
169
+ const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
170
+ const ymlExists = fs.existsSync(ymlPath);
171
+ const yamlExists = fs.existsSync(yamlPath);
172
+ if (ymlExists && yamlExists) {
173
+ throw new Error(`Routine '${config.name}' has both .yml and .yaml files; resolve the ambiguity before editing.`);
174
+ }
175
+ const filePath = ymlExists ? ymlPath : yamlExists ? yamlPath : ymlPath;
128
176
  const output = { ...config };
129
- if (output.mode === 'plan')
177
+ if (output.mode === 'auto')
130
178
  delete output.mode;
131
179
  if (output.effort === 'auto')
132
180
  delete output.effort;
@@ -136,7 +184,10 @@ export function writeJob(config) {
136
184
  delete output.enabled;
137
185
  if (output.runOnce === false || output.runOnce === undefined)
138
186
  delete output.runOnce;
139
- fs.writeFileSync(filePath, yaml.stringify(output), 'utf-8');
187
+ const devArr = output.devices;
188
+ if (!devArr || devArr.length === 0)
189
+ delete output.devices;
190
+ atomicWriteFileSync(filePath, yaml.stringify(output));
140
191
  }
141
192
  /** Delete a job config file by name. Returns true if the file existed. */
142
193
  export function deleteJob(name) {
@@ -219,6 +270,22 @@ export function validateJob(config) {
219
270
  errors.push('endAt must be a parseable ISO 8601 / RFC3339 timestamp (e.g., 2026-12-31T23:59:00Z)');
220
271
  }
221
272
  }
273
+ if (config.device !== undefined) {
274
+ errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
275
+ }
276
+ if (config.devices !== undefined) {
277
+ if (!Array.isArray(config.devices)) {
278
+ errors.push('devices must be an array of device names (as shown by `agents devices`)');
279
+ }
280
+ else {
281
+ for (const d of config.devices) {
282
+ if (typeof d !== 'string' || d.trim() === '') {
283
+ errors.push('each entry in devices must be a non-empty device name');
284
+ break;
285
+ }
286
+ }
287
+ }
288
+ }
222
289
  return errors;
223
290
  }
224
291
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -13,11 +13,11 @@
13
13
  * limit is detected mid-run (foreground `executeJob` only — detached daemon
14
14
  * fires once with the pre-flight pick).
15
15
  */
16
- import { spawn } from 'child_process';
16
+ import { spawn, execFileSync } from 'child_process';
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
- import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './routines.js';
20
+ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, } from './routines.js';
21
21
  import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
@@ -378,6 +378,10 @@ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
378
378
  * failover across healthy same-agent accounts (RUSH-1016).
379
379
  */
380
380
  export async function executeJob(config, deps) {
381
+ const eligibility = checkJobDeviceEligibility(config);
382
+ if (eligibility) {
383
+ throw new Error(eligibility.message);
384
+ }
381
385
  maybeRotate();
382
386
  const launch = await resolveRoutineLaunch(config);
383
387
  const primaryVersion = launch.chain[0]?.version ?? config.version;
@@ -407,6 +411,7 @@ export async function executeJob(config, deps) {
407
411
  agent: effectiveAgent,
408
412
  ...(config.workflow ? { workflow: config.workflow } : {}),
409
413
  pid: null,
414
+ spawnedAt: Date.now(),
410
415
  status: 'running',
411
416
  startedAt: new Date().toISOString(),
412
417
  completedAt: null,
@@ -536,6 +541,11 @@ export async function executeJob(config, deps) {
536
541
  }
537
542
  /** Spawn a job as a detached process and return immediately with run metadata. */
538
543
  export async function executeJobDetached(config) {
544
+ const eligibility = checkJobDeviceEligibility(config);
545
+ if (eligibility) {
546
+ process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
547
+ throw new Error(eligibility.message);
548
+ }
539
549
  // Pre-flight: pick a healthy version/account so the daemon does not launch
540
550
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
541
551
  // wait); the next schedule tick re-selects if this attempt still fails.
@@ -571,6 +581,7 @@ export async function executeJobDetached(config) {
571
581
  agent: effectiveAgent,
572
582
  ...(config.workflow ? { workflow: config.workflow } : {}),
573
583
  pid: null,
584
+ spawnedAt: Date.now(),
574
585
  status: 'running',
575
586
  startedAt: new Date().toISOString(),
576
587
  completedAt: null,
@@ -691,6 +702,45 @@ function inferFinalStatusFromLog(stdoutPath, agent) {
691
702
  return null;
692
703
  }
693
704
  }
705
+ const MAX_WALL_CLOCK_MS = 24 * 60 * 60 * 1000;
706
+ /**
707
+ * Verify that a PID still belongs to the process we spawned, not a recycled
708
+ * OS PID. Uses the recorded `spawnedAt` (epoch ms) from meta.json and
709
+ * compares against the process's actual start time via `ps`. Returns true
710
+ * when the PID is alive AND plausibly ours.
711
+ */
712
+ function isPidOurs(pid, spawnedAt) {
713
+ try {
714
+ process.kill(pid, 0);
715
+ }
716
+ catch {
717
+ return false;
718
+ }
719
+ if (spawnedAt === undefined)
720
+ return true;
721
+ if (process.platform === 'win32')
722
+ return true;
723
+ try {
724
+ const etime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
725
+ if (!etime)
726
+ return true;
727
+ const parts = etime.replace(/-/g, ':').split(':').reverse();
728
+ let uptimeSec = 0;
729
+ if (parts[0])
730
+ uptimeSec += parseInt(parts[0], 10);
731
+ if (parts[1])
732
+ uptimeSec += parseInt(parts[1], 10) * 60;
733
+ if (parts[2])
734
+ uptimeSec += parseInt(parts[2], 10) * 3600;
735
+ if (parts[3])
736
+ uptimeSec += parseInt(parts[3], 10) * 86400;
737
+ const processStartMs = Date.now() - uptimeSec * 1000;
738
+ return Math.abs(processStartMs - spawnedAt) < 30_000;
739
+ }
740
+ catch {
741
+ return true;
742
+ }
743
+ }
694
744
  /** Scan all runs marked "running" and finalize any whose process has exited. */
695
745
  export function monitorRunningJobs() {
696
746
  const runsDir = getRunsDir();
@@ -712,14 +762,17 @@ export function monitorRunningJobs() {
712
762
  continue;
713
763
  if (!meta.pid)
714
764
  continue;
715
- try {
716
- process.kill(meta.pid, 0);
765
+ const runDirPath = path.join(jobRunsPath, runDirEntry.name);
766
+ const stdoutPath = path.join(runDirPath, 'stdout.log');
767
+ const wallClockMs = Date.now() - Date.parse(meta.startedAt);
768
+ if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
769
+ meta.status = 'timeout';
770
+ meta.completedAt = new Date().toISOString();
771
+ writeRunMeta(meta);
772
+ extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
773
+ continue;
717
774
  }
718
- catch { /* process no longer running */
719
- const runDirPath = path.join(jobRunsPath, runDirEntry.name);
720
- const stdoutPath = path.join(runDirPath, 'stdout.log');
721
- // Prefer the agent's own success/error marker; fall back to "failed"
722
- // only when the stream ended without one (process killed mid-run).
775
+ if (!isPidOurs(meta.pid, meta.spawnedAt)) {
723
776
  const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
724
777
  if (inferred) {
725
778
  meta.status = inferred.status;
@@ -6,7 +6,7 @@
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
8
  import { Cron } from 'croner';
9
- import { listJobs, deleteJob, isPastEndAt, setJobEnabled } from './routines.js';
9
+ import { listJobs, deleteJob, isPastEndAt, setJobEnabled, jobRunsOnThisDevice } from './routines.js';
10
10
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
11
11
  export class JobScheduler {
12
12
  jobs = new Map();
@@ -18,8 +18,9 @@ export class JobScheduler {
18
18
  const configs = listJobs();
19
19
  for (const config of configs) {
20
20
  // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
- // not the cron loop — skip them here.
22
- if (config.enabled && config.schedule) {
21
+ // not the cron loop — skip them here. Jobs pinned to another device
22
+ // (routines are fleet-synced) never enter this machine's cron loop.
23
+ if (config.enabled && config.schedule && jobRunsOnThisDevice(config)) {
23
24
  this.schedule(config);
24
25
  }
25
26
  }
@@ -8,6 +8,7 @@
8
8
  * multiple permission modes (plan, edit, full).
9
9
  */
10
10
  import { spawn, execSync, execFileSync } from 'child_process';
11
+ import { getAgentsInvocation } from '../daemon.js';
11
12
  import * as fs from 'fs/promises';
12
13
  import * as fsSync from 'fs';
13
14
  import * as path from 'path';
@@ -1697,12 +1698,11 @@ export class AgentManager {
1697
1698
  return args;
1698
1699
  }
1699
1700
  buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1700
- const agentsCli = process.argv[1];
1701
- const cmd = [
1702
- process.execPath,
1703
- agentsCli,
1704
- ...this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName),
1705
- ];
1701
+ // Route through getAgentsInvocation so a teammate launched by the compiled
1702
+ // standalone binary (#315) doesn't relaunch as `agents /$bunfs/root/agents …`
1703
+ // (process.argv[1] is the bun virtual entry there) → "unknown command".
1704
+ const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName));
1705
+ const cmd = [inv.command, ...inv.args];
1706
1706
  if (cwd)
1707
1707
  cmd.push('--cwd', cwd);
1708
1708
  // Pin the session UUID to our agent_id so buildExecEnv keys
@@ -8,6 +8,8 @@
8
8
  * subcommand);
9
9
  * - stdout/stderr capture is consistent for the session module to parse.
10
10
  */
11
+ /** Oldest tmux release with `run-shell -C`, required by the managed pane-died hook. */
12
+ export declare const MIN_TMUX_VERSION = "3.2";
11
13
  /**
12
14
  * Locate the tmux binary on PATH. Cached after first call — tmux either is or
13
15
  * isn't installed for the duration of the process.
@@ -19,6 +21,8 @@ export declare function findTmuxBinary(): string | null;
19
21
  export declare function isTmuxInstalled(): boolean;
20
22
  /** Best-effort tmux version string (e.g. "tmux 3.6a"). Returns null when not installed or version probe fails. */
21
23
  export declare function getTmuxVersion(): string | null;
24
+ /** True for a `tmux -V` string at or above the supported 3.2 floor. */
25
+ export declare function isTmuxVersionSupported(version: string | null): boolean;
22
26
  /**
23
27
  * Throw a user-friendly error when tmux isn't installed. Command handlers call
24
28
  * this first thing so the error message is the same shape every time.
@@ -11,6 +11,9 @@
11
11
  import { spawn, spawnSync } from 'child_process';
12
12
  import { existsSync } from 'fs';
13
13
  let cachedBin;
14
+ let cachedVersion;
15
+ /** Oldest tmux release with `run-shell -C`, required by the managed pane-died hook. */
16
+ export const MIN_TMUX_VERSION = '3.2';
14
17
  /**
15
18
  * Locate the tmux binary on PATH. Cached after first call — tmux either is or
16
19
  * isn't installed for the duration of the process.
@@ -45,13 +48,29 @@ export function isTmuxInstalled() {
45
48
  }
46
49
  /** Best-effort tmux version string (e.g. "tmux 3.6a"). Returns null when not installed or version probe fails. */
47
50
  export function getTmuxVersion() {
51
+ if (cachedVersion !== undefined)
52
+ return cachedVersion;
48
53
  const bin = findTmuxBinary();
49
54
  if (!bin)
50
55
  return null;
51
56
  const res = spawnSync(bin, ['-V'], { encoding: 'utf8' });
52
- if (res.status !== 0)
53
- return null;
54
- return res.stdout.trim() || null;
57
+ if (res.status !== 0) {
58
+ cachedVersion = null;
59
+ return cachedVersion;
60
+ }
61
+ cachedVersion = res.stdout.trim() || null;
62
+ return cachedVersion;
63
+ }
64
+ /** True for a `tmux -V` string at or above the supported 3.2 floor. */
65
+ export function isTmuxVersionSupported(version) {
66
+ if (!version)
67
+ return false;
68
+ const match = /^tmux\s+(\d+)\.(\d+)/.exec(version.trim());
69
+ if (!match)
70
+ return false;
71
+ const major = Number(match[1]);
72
+ const minor = Number(match[2]);
73
+ return major > 3 || (major === 3 && minor >= 2);
55
74
  }
56
75
  /**
57
76
  * Throw a user-friendly error when tmux isn't installed. Command handlers call
@@ -68,6 +87,10 @@ export function assertTmuxAvailable() {
68
87
  : 'Install tmux from https://github.com/tmux/tmux';
69
88
  throw new TmuxUnavailableError(`tmux is not installed. ${hint}`);
70
89
  }
90
+ const version = getTmuxVersion();
91
+ if (!isTmuxVersionSupported(version)) {
92
+ throw new TmuxUnavailableError(`${version ?? 'tmux version unknown'} is unsupported. agents requires tmux ${MIN_TMUX_VERSION} or newer.`);
93
+ }
71
94
  return bin;
72
95
  }
73
96
  export class TmuxUnavailableError extends Error {
@@ -3,6 +3,6 @@
3
3
  * (swarmify extension, `agents teams` multiplexer mode, future MCP wrapper)
4
4
  * should import from here.
5
5
  */
6
- export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
6
+ export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, isTmuxVersionSupported, MIN_TMUX_VERSION, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
7
7
  export { getDefaultSocketPath, getSessionMetaPath, ensureTmuxDir, } from './paths.js';
8
8
  export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, type SessionMeta, type CreateSessionOptions, type ListedSession, type SplitOptions, type SendOptions, type CaptureOptions, } from './session.js';
@@ -3,6 +3,6 @@
3
3
  * (swarmify extension, `agents teams` multiplexer mode, future MCP wrapper)
4
4
  * should import from here.
5
5
  */
6
- export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
6
+ export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, isTmuxVersionSupported, MIN_TMUX_VERSION, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
7
7
  export { getDefaultSocketPath, getSessionMetaPath, ensureTmuxDir, } from './paths.js';
8
8
  export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, } from './session.js';