@phnx-labs/agents-cli 1.20.53 → 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.
@@ -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
  }
@@ -16,6 +16,19 @@ export declare function shellQuote(s: string): string;
16
16
  * (`[runtime, script, 'sessions', ...]`).
17
17
  */
18
18
  export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
19
+ /**
20
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
21
+ *
22
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
23
+ * default listing is silently cwd-scoped, so `sessions --host box` reads as
24
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
25
+ * Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
26
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
27
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
28
+ * a query that looks like a path takes precedence over `--all` on the remote.
29
+ * Idempotent: never adds a second `--all`.
30
+ */
31
+ export declare function ensureWholeIndex(forwardedArgs: string[]): string[];
19
32
  /**
20
33
  * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
21
34
  * are quoted for the inner login shell, then the whole `agents …` invocation is
@@ -30,6 +30,7 @@ import { getCacheDir } from '../state.js';
30
30
  import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
31
31
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
32
32
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
33
+ import { NO_FANOUT_ENV } from './remote-active.js';
33
34
  import { formatRelativeTime } from './relative-time.js';
34
35
  import { terminalWidth } from './width.js';
35
36
  /**
@@ -81,6 +82,21 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
81
82
  }
82
83
  return out;
83
84
  }
85
+ /**
86
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
87
+ *
88
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
89
+ * default listing is silently cwd-scoped, so `sessions --host box` reads as
90
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
91
+ * Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
92
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
93
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
94
+ * a query that looks like a path takes precedence over `--all` on the remote.
95
+ * Idempotent: never adds a second `--all`.
96
+ */
97
+ export function ensureWholeIndex(forwardedArgs) {
98
+ return forwardedArgs.includes('--all') ? forwardedArgs : [...forwardedArgs, '--all'];
99
+ }
84
100
  /**
85
101
  * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
86
102
  * are quoted for the inner login shell, then the whole `agents …` invocation is
@@ -93,16 +109,23 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
93
109
  * remote renders its table to the local screen.
94
110
  */
95
111
  export function buildRemoteCommand(forwardedArgs, columns, os) {
112
+ // `--host <box>` means "that box's own sessions" — so the peer must answer for
113
+ // ITSELF and not re-sweep its fleet. Without this the remote `agents sessions`
114
+ // fans back out to every device IT knows (including us), printing a spurious
115
+ // `<this-machine>: unreachable`. AGENTS_SESSIONS_LOCAL=1 pins the peer local,
116
+ // matching the JSON fan-out path (`remote-list.ts`).
96
117
  if (remoteShellFor(os) === 'powershell') {
97
- const env = columns && columns > 0 ? { COLUMNS: String(columns) } : undefined;
118
+ const env = { [NO_FANOUT_ENV]: '1' };
119
+ if (columns && columns > 0)
120
+ env.COLUMNS = String(columns);
98
121
  return buildWindowsAgentsCommand({ args: forwardedArgs, env });
99
122
  }
100
123
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
101
124
  // Forward the caller's terminal width so the remote renders the table to the
102
125
  // local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
103
126
  // cmd` scopes the env to that process — the remote's terminalWidth() reads it.
104
- const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
105
- return `bash -lc ${shellQuote(withCols)}`;
127
+ const envPrefix = `${NO_FANOUT_ENV}=1` + (columns && columns > 0 ? ` COLUMNS=${columns}` : '');
128
+ return `bash -lc ${shellQuote(`${envPrefix} ${inner}`)}`;
106
129
  }
107
130
  /**
108
131
  * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
@@ -182,7 +205,7 @@ function replayRemoteCache(host, forwardedArgs) {
182
205
  export function runRemoteSessions(hosts, argv = process.argv) {
183
206
  for (const host of hosts)
184
207
  assertValidSshTarget(host); // fail fast on any bad target
185
- const forwarded = buildForwardedArgs(argv, new Set(hosts));
208
+ const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
186
209
  const cols = terminalWidth();
187
210
  const multi = hosts.length > 1;
188
211
  let failures = 0;
@@ -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';
@@ -123,9 +123,10 @@ export declare function paneExitStatus(pane: string, socket?: string): Promise<P
123
123
  * Bind a per-session hook. Used by the spawn-wrap path to install a `pane-died`
124
124
  * hook that detaches the attach client the instant the wrapped agent exits (the
125
125
  * global `remain-on-exit on` otherwise leaves the client staring at a dead pane).
126
- * Best-effort — a failed hook just means the user Ctrl-b d's out manually.
126
+ * Best-effort — returns false when tmux rejects the hook so callers do not
127
+ * stamp a schema marker and daemon reconciliation can retry later.
127
128
  */
128
- export declare function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise<void>;
129
+ export declare function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise<boolean>;
129
130
  /**
130
131
  * Schema version of the `pane-died` hook installed on managed `agents run`
131
132
  * sessions. Bump whenever the hook's SHAPE changes so the daemon reconcile
@@ -135,15 +136,30 @@ export declare function setSessionHook(name: string, hook: string, command: stri
135
136
  * user exiting a split they opened) tore down the whole client.
136
137
  * v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
137
138
  * split's death runs `kill-pane`, closing just that split.
139
+ * v3 — the else-branch pins its target via
140
+ * `run-shell "tmux -S <socket> kill-pane -t #{hook_pane}"`. Untargeted
141
+ * kill-pane resolves "current pane" inside the hook context, which goes
142
+ * nondeterministic on a loaded detached server (CI flake #965: the dead
143
+ * split survived as a husk); run-shell format-expands its command at
144
+ * fire time, so the event pane is always the target.
145
+ * v4 — `run-shell -C "kill-pane -t #{hook_pane}"` executes the targeted command
146
+ * in the tmux server instead of launching a second tmux client against
147
+ * the same socket from inside the hook. That self-client could race the
148
+ * server under load and leave the dead split behind.
138
149
  */
139
- export declare const AGENT_HOOK_SCHEMA = 2;
150
+ export declare const AGENT_HOOK_SCHEMA = 4;
140
151
  /**
141
152
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
142
153
  * (so the blocking attach in runInTmux returns and the exit status can be read);
143
- * a user split's death falls through to `kill-pane`, which because the hook
144
- * runs in the dead pane's context closes that split in place. Single source of
145
- * truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
146
- * here, so the two can never drift.
154
+ * a user split's death runs the else-branch, closing just that split. The
155
+ * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
156
+ * target: tmux format-expands the command at fire time and executes it inside
157
+ * the server command queue, so the event pane is always the one killed without
158
+ * launching a second tmux client against the same socket. A bare `kill-pane`
159
+ * relied on the hook context supplying a "current pane", while an external
160
+ * self-client could race the server under load. Single source of truth: both
161
+ * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
162
+ * two can never drift.
147
163
  */
148
164
  export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
149
165
  /** Stamp a session's hook-schema marker to the current version. */
@@ -262,12 +262,18 @@ export async function paneExitStatus(pane, socket) {
262
262
  * Bind a per-session hook. Used by the spawn-wrap path to install a `pane-died`
263
263
  * hook that detaches the attach client the instant the wrapped agent exits (the
264
264
  * global `remain-on-exit on` otherwise leaves the client staring at a dead pane).
265
- * Best-effort — a failed hook just means the user Ctrl-b d's out manually.
265
+ * Best-effort — returns false when tmux rejects the hook so callers do not
266
+ * stamp a schema marker and daemon reconciliation can retry later.
266
267
  */
267
268
  export async function setSessionHook(name, hook, command, socket) {
268
269
  assertValidSessionName(name);
269
270
  const sock = socket ?? getDefaultSocketPath();
270
- await runTmux({ socket: sock, args: ['set-hook', '-t', name, hook, command], throwOnError: false }).catch(() => { });
271
+ const result = await runTmux({
272
+ socket: sock,
273
+ args: ['set-hook', '-t', name, hook, command],
274
+ throwOnError: false,
275
+ }).catch(() => null);
276
+ return result?.code === 0;
271
277
  }
272
278
  /**
273
279
  * Schema version of the `pane-died` hook installed on managed `agents run`
@@ -278,20 +284,35 @@ export async function setSessionHook(name, hook, command, socket) {
278
284
  * user exiting a split they opened) tore down the whole client.
279
285
  * v2 — `#{hook_pane}`-guarded: only the AGENT pane dying detaches; a user
280
286
  * split's death runs `kill-pane`, closing just that split.
287
+ * v3 — the else-branch pins its target via
288
+ * `run-shell "tmux -S <socket> kill-pane -t #{hook_pane}"`. Untargeted
289
+ * kill-pane resolves "current pane" inside the hook context, which goes
290
+ * nondeterministic on a loaded detached server (CI flake #965: the dead
291
+ * split survived as a husk); run-shell format-expands its command at
292
+ * fire time, so the event pane is always the target.
293
+ * v4 — `run-shell -C "kill-pane -t #{hook_pane}"` executes the targeted command
294
+ * in the tmux server instead of launching a second tmux client against
295
+ * the same socket from inside the hook. That self-client could race the
296
+ * server under load and leave the dead split behind.
281
297
  */
282
- export const AGENT_HOOK_SCHEMA = 2;
298
+ export const AGENT_HOOK_SCHEMA = 4;
283
299
  /** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
284
300
  const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
285
301
  /**
286
302
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
287
303
  * (so the blocking attach in runInTmux returns and the exit status can be read);
288
- * a user split's death falls through to `kill-pane`, which because the hook
289
- * runs in the dead pane's context closes that split in place. Single source of
290
- * truth: both the spawn-wrap (exec.ts) and the daemon reconcile build the hook
291
- * here, so the two can never drift.
304
+ * a user split's death runs the else-branch, closing just that split. The
305
+ * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
306
+ * target: tmux format-expands the command at fire time and executes it inside
307
+ * the server command queue, so the event pane is always the one killed without
308
+ * launching a second tmux client against the same socket. A bare `kill-pane`
309
+ * relied on the hook context supplying a "current pane", while an external
310
+ * self-client could race the server under load. Single source of truth: both
311
+ * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
312
+ * two can never drift.
292
313
  */
293
314
  export function agentPaneDiedHook(sessionName, agentPane) {
294
- return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'kill-pane'`;
315
+ return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -C "kill-pane -t #{hook_pane}"'`;
295
316
  }
296
317
  /** Stamp a session's hook-schema marker to the current version. */
297
318
  export async function markSessionHookSchema(name, socket) {
@@ -355,7 +376,9 @@ export async function reconcileSessionHooks(socket) {
355
376
  const agentPane = s.meta?.pane ?? await lowestPaneId(s.name, sock);
356
377
  if (!agentPane)
357
378
  continue;
358
- await setSessionHook(s.name, 'pane-died', agentPaneDiedHook(s.name, agentPane), sock);
379
+ const installed = await setSessionHook(s.name, 'pane-died', agentPaneDiedHook(s.name, agentPane), sock);
380
+ if (!installed)
381
+ continue;
359
382
  await markSessionHookSchema(s.name, sock);
360
383
  reconciled++;
361
384
  }
@@ -12,7 +12,7 @@
12
12
  * The optional http listener (`startWebhookServer`) is a thin adapter over it.
13
13
  */
14
14
  import * as http from 'http';
15
- import { listJobs } from '../routines.js';
15
+ import { listJobs, jobRunsOnThisDevice } from '../routines.js';
16
16
  import { executeJobDetached } from '../runner.js';
17
17
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
18
18
  export function webhookRepo(payload) {
@@ -85,7 +85,7 @@ export function jobMatchesWebhook(job, webhook) {
85
85
  * time-based jobs are unaffected by webhook delivery.
86
86
  */
87
87
  export function matchJobsToWebhook(jobs, webhook) {
88
- return jobs.filter((job) => job.enabled !== false && jobMatchesWebhook(job, webhook));
88
+ return jobs.filter((job) => job.enabled !== false && jobRunsOnThisDevice(job) && jobMatchesWebhook(job, webhook));
89
89
  }
90
90
  /**
91
91
  * Match an incoming webhook against the persisted routines and fire each match
@@ -439,6 +439,12 @@ export interface SkillEntry {
439
439
  tags?: string[];
440
440
  /** Registry-specific trust signal (e.g. 'builtin', 'trusted', 'community'). */
441
441
  trustLevel?: string;
442
+ /**
443
+ * Lowercase hex sha256 of the skill's SKILL.md, as recorded by
444
+ * `agents publish`. When present, install verifies the cloned SKILL.md
445
+ * against it and aborts on mismatch.
446
+ */
447
+ sha256?: string;
442
448
  }
443
449
  /** Paginated response from a skill registry search endpoint. */
444
450
  export interface SkillRegistryResponse {