@phnx-labs/agents-cli 1.20.54 → 1.20.55

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.
@@ -11,6 +11,13 @@ import type { PendingDevice } from './pending.js';
11
11
  * the wrong account). Returns undefined when the username isn't a safe ssh
12
12
  * identifier, so a weird value never lands in the registry. */
13
13
  export declare function localLoginUser(): string | undefined;
14
+ /**
15
+ * Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
16
+ * the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
17
+ * after the backslash — without this strip the `\` fails the charset guard and
18
+ * Windows boxes never pin a user. Pure, so the platform-specific munging is
19
+ * unit-tested without reading the real OS user. */
20
+ export declare function sanitizeLoginUser(raw: string | undefined): string | undefined;
14
21
  /**
15
22
  * Fill in a device's login user during sync WITHOUT ever clobbering an account
16
23
  * the user pinned. Precedence: an existing registered user wins; else the local
@@ -34,7 +34,19 @@ export function localLoginUser() {
34
34
  catch {
35
35
  u = process.env.USER || process.env.USERNAME || undefined;
36
36
  }
37
- return u && /^[a-zA-Z0-9._-]+$/.test(u) ? u : undefined;
37
+ return sanitizeLoginUser(u);
38
+ }
39
+ /**
40
+ * Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
41
+ * the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
42
+ * after the backslash — without this strip the `\` fails the charset guard and
43
+ * Windows boxes never pin a user. Pure, so the platform-specific munging is
44
+ * unit-tested without reading the real OS user. */
45
+ export function sanitizeLoginUser(raw) {
46
+ if (!raw)
47
+ return undefined;
48
+ const bare = raw.includes('\\') ? raw.slice(raw.lastIndexOf('\\') + 1) : raw;
49
+ return /^[a-zA-Z0-9._-]+$/.test(bare) ? bare : undefined;
38
50
  }
39
51
  /**
40
52
  * Fill in a device's login user during sync WITHOUT ever clobbering an account
package/dist/lib/exec.js CHANGED
@@ -927,14 +927,16 @@ async function runInTmux(options, executable, args) {
927
927
  // When the AGENT pane dies, detach the client (don't kill) so the session
928
928
  // survives just long enough to read the dead pane's exit status below. The
929
929
  // `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
930
- // the window and exits one of THEIR panes, the else-branch `kill-pane` closes
931
- // that split in place instead of detaching everyone (the pane-died hook runs
932
- // in the dead pane's context, so bare `kill-pane` targets it). Without the
933
- // guard, exiting any split kicked the user clean out of tmux.
934
- await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
935
- // Stamp the schema marker so the daemon reconcile (which retrofits older
936
- // sessions) recognizes this one as already current and skips it.
937
- await markSessionHookSchema(name, socket);
930
+ // the window and exits one of THEIR panes, the else-branch closes that split
931
+ // in place instead of detaching everyone (`run-shell -C` executes the
932
+ // targeted kill inside tmux's server command queue, avoiding a second
933
+ // client racing the same socket under load, #965). Without the guard,
934
+ // exiting any split kicked the user clean out of tmux.
935
+ const hookInstalled = await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
936
+ // Stamp the schema marker only after tmux accepted the hook. A failed
937
+ // install stays unmarked so daemon reconciliation retries it later.
938
+ if (hookInstalled)
939
+ await markSessionHookSchema(name, socket);
938
940
  // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
939
941
  // pane so the active-scan attributes it exactly and shows the %pane.
940
942
  let panePid = 0;
@@ -14,7 +14,7 @@
14
14
  import { Cron } from 'croner';
15
15
  import * as os from 'os';
16
16
  import { spawn } from 'child_process';
17
- import { listJobs, getLatestRun } from './routines.js';
17
+ import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
18
18
  // Tolerance between "expected fire" and "recorded run start" — accounts for
19
19
  // the small gap between the cron tick and when the runner writes meta.json.
20
20
  const GRACE_MS = 60_000;
@@ -47,6 +47,11 @@ export function detectOverdueJobs(now = new Date()) {
47
47
  // Trigger-only jobs (no cron schedule) never have an expected fire time.
48
48
  if (!job.schedule)
49
49
  continue;
50
+ // A job pinned to another device is that device's to run, notify, and
51
+ // catch up — flagging it here would make every machine in the fleet nag
52
+ // (and `catchup` fire) for a job that must not run locally.
53
+ if (!jobRunsOnThisDevice(job))
54
+ continue;
50
55
  let expected = null;
51
56
  try {
52
57
  const cronOptions = { paused: true };
@@ -88,6 +88,58 @@ export const PRESETS = [
88
88
  ANTHROPIC_SMALL_FAST_MODEL: 'deepseek/deepseek-chat-v3-0324',
89
89
  },
90
90
  },
91
+ {
92
+ name: 'open-claude',
93
+ description: 'Open-weight coding via OpenRouter inside Claude Code (Qwen3 Coder Next, 256K ctx, $0.15/$0.80 per 1M). HEADLESS-SAFE — best general preset for open-claude usage with Claude Code harness.',
94
+ ...OPENROUTER_AUTH,
95
+ env: {
96
+ ANTHROPIC_BASE_URL: OPENROUTER_BASE,
97
+ ANTHROPIC_MODEL: 'qwen/qwen3-coder-next',
98
+ ANTHROPIC_SMALL_FAST_MODEL: 'qwen/qwen3-coder-next',
99
+ },
100
+ },
101
+ {
102
+ name: 'claude-spark',
103
+ description: 'Meta Claude Spark 1.1 via OpenRouter inside Claude Code (open alternative). Model: meta/claude-spark-1.1 — free via opencode, now usable in Claude Code UI. HEADLESS-SAFE. For open-claude spark usage.',
104
+ ...OPENROUTER_AUTH,
105
+ env: {
106
+ ANTHROPIC_BASE_URL: OPENROUTER_BASE,
107
+ ANTHROPIC_MODEL: 'meta/claude-spark-1.1',
108
+ ANTHROPIC_SMALL_FAST_MODEL: 'meta/claude-spark-1.1',
109
+ },
110
+ },
111
+ // ----- OpenCode CLI (open-claude harness) -----
112
+ {
113
+ name: 'opencode',
114
+ description: 'OpenCode default — uses your configured model via opencode auth. Run `opencode auth` to login, then `agents run opencode --model meta/claude-spark-1.1 "prompt"` for spark usage.',
115
+ provider: 'opencode',
116
+ host: 'opencode',
117
+ authEnvVar: 'OPENCODE_API_KEY',
118
+ authOptional: true,
119
+ env: {},
120
+ },
121
+ {
122
+ name: 'opencode-spark',
123
+ description: 'Meta Claude Spark 1.1 via OpenCode (free, headless-safe). Pinned to meta/claude-spark-1.1 — best for open-claude usage with opencode harness.',
124
+ provider: 'opencode',
125
+ host: 'opencode',
126
+ authEnvVar: 'OPENCODE_API_KEY',
127
+ authOptional: true,
128
+ env: {
129
+ OPENCODE_MODEL: 'meta/claude-spark-1.1',
130
+ },
131
+ },
132
+ {
133
+ name: 'opencode-qwen',
134
+ description: 'Qwen3 Coder Next via OpenCode (open-claude path). Use `agents run opencode-qwen "prompt"` — free via opencode provider.',
135
+ provider: 'opencode',
136
+ host: 'opencode',
137
+ authEnvVar: 'OPENCODE_API_KEY',
138
+ authOptional: true,
139
+ env: {
140
+ OPENCODE_MODEL: 'qwen/qwen3-coder-next',
141
+ },
142
+ },
91
143
  // ----- xAI Grok Build CLI (native host) -----
92
144
  {
93
145
  name: 'grok-fast',
@@ -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,15 @@ export interface JobConfig {
60
60
  prompt: string;
61
61
  timezone?: string;
62
62
  repo?: string;
63
+ /**
64
+ * Pin this routine to one machine. `~/.agents/routines/` is synced to every
65
+ * device via the user repo, so without a pin an enabled routine fires on
66
+ * EVERY machine running the scheduler. When set, only the device whose
67
+ * `machineId()` matches (normalized hostname, e.g. `yosemite-s0`) schedules,
68
+ * fires, catches up, or counts this job as overdue; everywhere else it is
69
+ * inert and `run` refuses with an `agents ssh` pointer.
70
+ */
71
+ device?: string;
63
72
  variables?: Record<string, string>;
64
73
  sandbox?: boolean;
65
74
  allow?: JobAllowConfig;
@@ -77,11 +86,20 @@ export interface RunMeta {
77
86
  agent: AgentId;
78
87
  workflow?: string;
79
88
  pid: number | null;
89
+ /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
90
+ spawnedAt?: number;
80
91
  status: 'running' | 'completed' | 'failed' | 'timeout';
81
92
  startedAt: string;
82
93
  completedAt: string | null;
83
94
  exitCode: number | null;
84
95
  }
96
+ /**
97
+ * True when the job may execute on this machine: no `device` pin, or the pin
98
+ * names this device. Both sides go through `normalizeHost` so `Yosemite-S0`,
99
+ * `yosemite-s0.tailnet.ts.net`, and `yosemite-s0` all agree. Every fire path
100
+ * (cron scheduler, webhook, catchup/overdue, manual run) gates on this.
101
+ */
102
+ export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'device'>): boolean;
85
103
  /**
86
104
  * List all job configs, scanning project > user routine dirs.
87
105
  * Project routines (`<project>/.agents/routines/`) shadow user routines of the
@@ -13,6 +13,7 @@ import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin } from './paths.js';
15
15
  import { ALL_AGENT_IDS } from './agents.js';
16
+ import { machineId, normalizeHost } from './machine-id.js';
16
17
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
17
18
  export const GITHUB_TRIGGER_EVENTS = [
18
19
  'pull_request',
@@ -40,9 +41,20 @@ export function normalizeTriggerEvent(input) {
40
41
  };
41
42
  return aliases[key] ?? null;
42
43
  }
44
+ /**
45
+ * True when the job may execute on this machine: no `device` pin, or the pin
46
+ * names this device. Both sides go through `normalizeHost` so `Yosemite-S0`,
47
+ * `yosemite-s0.tailnet.ts.net`, and `yosemite-s0` all agree. Every fire path
48
+ * (cron scheduler, webhook, catchup/overdue, manual run) gates on this.
49
+ */
50
+ export function jobRunsOnThisDevice(config) {
51
+ if (!config.device)
52
+ return true;
53
+ return normalizeHost(config.device) === machineId();
54
+ }
43
55
  /** Default values applied to every job config when fields are omitted. */
44
56
  const JOB_DEFAULTS = {
45
- mode: 'plan',
57
+ mode: 'auto',
46
58
  effort: 'auto',
47
59
  timeout: '10m',
48
60
  enabled: true,
@@ -126,7 +138,7 @@ export function writeJob(config) {
126
138
  const jobsDir = getRoutinesDir();
127
139
  const filePath = safeJoin(jobsDir, config.name + '.yml');
128
140
  const output = { ...config };
129
- if (output.mode === 'plan')
141
+ if (output.mode === 'auto')
130
142
  delete output.mode;
131
143
  if (output.effort === 'auto')
132
144
  delete output.effort;
@@ -219,6 +231,11 @@ export function validateJob(config) {
219
231
  errors.push('endAt must be a parseable ISO 8601 / RFC3339 timestamp (e.g., 2026-12-31T23:59:00Z)');
220
232
  }
221
233
  }
234
+ if (config.device !== undefined) {
235
+ if (typeof config.device !== 'string' || config.device.trim() === '') {
236
+ errors.push('device must be a non-empty device name (as shown by `agents devices`, e.g. yosemite-s0)');
237
+ }
238
+ }
222
239
  return errors;
223
240
  }
224
241
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -13,7 +13,7 @@
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';
@@ -407,6 +407,7 @@ export async function executeJob(config, deps) {
407
407
  agent: effectiveAgent,
408
408
  ...(config.workflow ? { workflow: config.workflow } : {}),
409
409
  pid: null,
410
+ spawnedAt: Date.now(),
410
411
  status: 'running',
411
412
  startedAt: new Date().toISOString(),
412
413
  completedAt: null,
@@ -571,6 +572,7 @@ export async function executeJobDetached(config) {
571
572
  agent: effectiveAgent,
572
573
  ...(config.workflow ? { workflow: config.workflow } : {}),
573
574
  pid: null,
575
+ spawnedAt: Date.now(),
574
576
  status: 'running',
575
577
  startedAt: new Date().toISOString(),
576
578
  completedAt: null,
@@ -691,6 +693,45 @@ function inferFinalStatusFromLog(stdoutPath, agent) {
691
693
  return null;
692
694
  }
693
695
  }
696
+ const MAX_WALL_CLOCK_MS = 24 * 60 * 60 * 1000;
697
+ /**
698
+ * Verify that a PID still belongs to the process we spawned, not a recycled
699
+ * OS PID. Uses the recorded `spawnedAt` (epoch ms) from meta.json and
700
+ * compares against the process's actual start time via `ps`. Returns true
701
+ * when the PID is alive AND plausibly ours.
702
+ */
703
+ function isPidOurs(pid, spawnedAt) {
704
+ try {
705
+ process.kill(pid, 0);
706
+ }
707
+ catch {
708
+ return false;
709
+ }
710
+ if (spawnedAt === undefined)
711
+ return true;
712
+ if (process.platform === 'win32')
713
+ return true;
714
+ try {
715
+ const etime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
716
+ if (!etime)
717
+ return true;
718
+ const parts = etime.replace(/-/g, ':').split(':').reverse();
719
+ let uptimeSec = 0;
720
+ if (parts[0])
721
+ uptimeSec += parseInt(parts[0], 10);
722
+ if (parts[1])
723
+ uptimeSec += parseInt(parts[1], 10) * 60;
724
+ if (parts[2])
725
+ uptimeSec += parseInt(parts[2], 10) * 3600;
726
+ if (parts[3])
727
+ uptimeSec += parseInt(parts[3], 10) * 86400;
728
+ const processStartMs = Date.now() - uptimeSec * 1000;
729
+ return Math.abs(processStartMs - spawnedAt) < 30_000;
730
+ }
731
+ catch {
732
+ return true;
733
+ }
734
+ }
694
735
  /** Scan all runs marked "running" and finalize any whose process has exited. */
695
736
  export function monitorRunningJobs() {
696
737
  const runsDir = getRunsDir();
@@ -712,14 +753,17 @@ export function monitorRunningJobs() {
712
753
  continue;
713
754
  if (!meta.pid)
714
755
  continue;
715
- try {
716
- process.kill(meta.pid, 0);
756
+ const runDirPath = path.join(jobRunsPath, runDirEntry.name);
757
+ const stdoutPath = path.join(runDirPath, 'stdout.log');
758
+ const wallClockMs = Date.now() - Date.parse(meta.startedAt);
759
+ if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
760
+ meta.status = 'timeout';
761
+ meta.completedAt = new Date().toISOString();
762
+ writeRunMeta(meta);
763
+ extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
764
+ continue;
717
765
  }
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).
766
+ if (!isPidOurs(meta.pid, meta.spawnedAt)) {
723
767
  const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
724
768
  if (inferred) {
725
769
  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,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';