@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.
- package/CHANGELOG.md +18 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/packages.js +113 -2
- package/dist/commands/routines.js +55 -15
- package/dist/commands/sessions.js +5 -3
- package/dist/commands/ssh.js +19 -14
- package/dist/commands/tmux.js +11 -3
- package/dist/lib/daemon.d.ts +14 -0
- package/dist/lib/daemon.js +91 -6
- package/dist/lib/devices/resolve-target.d.ts +21 -4
- package/dist/lib/devices/resolve-target.js +82 -14
- package/dist/lib/devices/sync.d.ts +25 -0
- package/dist/lib/devices/sync.js +46 -1
- package/dist/lib/exec.js +10 -8
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.js +6 -1
- package/dist/lib/profiles-presets.js +52 -0
- package/dist/lib/registry.d.ts +55 -0
- package/dist/lib/registry.js +82 -1
- package/dist/lib/routines.d.ts +18 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +52 -8
- package/dist/lib/scheduler.js +4 -3
- package/dist/lib/session/remote.d.ts +13 -0
- package/dist/lib/session/remote.js +27 -4
- package/dist/lib/tmux/binary.d.ts +4 -0
- package/dist/lib/tmux/binary.js +26 -3
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +23 -7
- package/dist/lib/tmux/session.js +32 -9
- package/dist/lib/triggers/webhook.js +2 -2
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/lib/registry.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/routines.js
CHANGED
|
@@ -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: '
|
|
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 === '
|
|
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. */
|
package/dist/lib/runner.js
CHANGED
|
@@ -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
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
105
|
-
return `bash -lc ${shellQuote(
|
|
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.
|
package/dist/lib/tmux/binary.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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 {
|
package/dist/lib/tmux/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/lib/tmux/index.js
CHANGED
|
@@ -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 —
|
|
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<
|
|
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 =
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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. */
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
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 =
|
|
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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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 {
|