@phnx-labs/agents-cli 1.20.54 → 1.20.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +9 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/message.js +7 -5
- package/dist/commands/packages.js +113 -2
- package/dist/commands/profiles.js +4 -3
- package/dist/commands/routines.js +185 -15
- package/dist/commands/ssh.js +2 -9
- package/dist/commands/tmux.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/daemon.d.ts +33 -1
- package/dist/lib/daemon.js +131 -14
- package/dist/lib/devices/sync.d.ts +7 -0
- package/dist/lib/devices/sync.js +13 -1
- package/dist/lib/exec.js +10 -8
- package/dist/lib/hosts/passthrough.js +8 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +9 -0
- package/dist/lib/migrate.js +48 -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 +42 -1
- package/dist/lib/routines.js +72 -5
- package/dist/lib/runner.js +62 -9
- package/dist/lib/scheduler.js +4 -3
- package/dist/lib/teams/agents.js +6 -6
- 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.d.ts
CHANGED
|
@@ -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
|
+
};
|
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,14 @@ export interface JobConfig {
|
|
|
60
60
|
prompt: string;
|
|
61
61
|
timezone?: string;
|
|
62
62
|
repo?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Fleet allowlist — restrict this routine to specific devices. When omitted
|
|
65
|
+
* or empty, the routine is unrestricted and fires on every device running the
|
|
66
|
+
* scheduler. When set, only devices whose `machineId()` matches any entry
|
|
67
|
+
* (via `normalizeHost`) schedule, fire, catch up, or count this job as
|
|
68
|
+
* overdue; everywhere else it is inert and `run` refuses with a pointer.
|
|
69
|
+
*/
|
|
70
|
+
devices?: string[];
|
|
63
71
|
variables?: Record<string, string>;
|
|
64
72
|
sandbox?: boolean;
|
|
65
73
|
allow?: JobAllowConfig;
|
|
@@ -77,11 +85,39 @@ export interface RunMeta {
|
|
|
77
85
|
agent: AgentId;
|
|
78
86
|
workflow?: string;
|
|
79
87
|
pid: number | null;
|
|
88
|
+
/** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
|
|
89
|
+
spawnedAt?: number;
|
|
80
90
|
status: 'running' | 'completed' | 'failed' | 'timeout';
|
|
81
91
|
startedAt: string;
|
|
82
92
|
completedAt: string | null;
|
|
83
93
|
exitCode: number | null;
|
|
84
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* True when the job may execute on this machine: no `devices` allowlist (or
|
|
97
|
+
* empty), or the allowlist includes this device. Both sides go through
|
|
98
|
+
* `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
|
|
99
|
+
* `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
|
|
100
|
+
* catchup/overdue, manual run) gates on this.
|
|
101
|
+
*/
|
|
102
|
+
export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
|
|
103
|
+
/** Human presentation of a device-affinity mismatch for commands and runner. */
|
|
104
|
+
export interface JobEligibilityResult {
|
|
105
|
+
/** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
|
|
106
|
+
message: string;
|
|
107
|
+
/** One-line copy-paste suggestion, e.g. "agents routines run NAME --host a". */
|
|
108
|
+
suggestion: string;
|
|
109
|
+
/** Comma-separated allowed devices label, e.g. "a, b". */
|
|
110
|
+
allowedLabel: string;
|
|
111
|
+
/** First allowed device (normalized), useful for the suggested host. */
|
|
112
|
+
firstHost: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Return null when the job may run here; otherwise return a structured,
|
|
116
|
+
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
117
|
+
* construction so manual run, executeJob, and executeJobDetached stay in
|
|
118
|
+
* sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
|
|
119
|
+
*/
|
|
120
|
+
export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
|
|
85
121
|
/**
|
|
86
122
|
* List all job configs, scanning project > user routine dirs.
|
|
87
123
|
* Project routines (`<project>/.agents/routines/`) shadow user routines of the
|
|
@@ -95,7 +131,12 @@ export declare function listJobs(cwd?: string): JobConfig[];
|
|
|
95
131
|
* only resolve user routines.
|
|
96
132
|
*/
|
|
97
133
|
export declare function readJob(name: string, cwd?: string): JobConfig | null;
|
|
98
|
-
/** Write a job config to disk, omitting fields that match defaults.
|
|
134
|
+
/** Write a job config to disk, omitting fields that match defaults.
|
|
135
|
+
*
|
|
136
|
+
* Updates the one existing supported extension (.yml or .yaml) atomically.
|
|
137
|
+
* New routines are written as .yml. If both extensions exist for the same
|
|
138
|
+
* name, the write fails explicitly so we never choose or drop a sibling.
|
|
139
|
+
*/
|
|
99
140
|
export declare function writeJob(config: JobConfig): void;
|
|
100
141
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
101
142
|
export declare function deleteJob(name: string): boolean;
|
package/dist/lib/routines.js
CHANGED
|
@@ -12,7 +12,9 @@ import * as yaml from 'yaml';
|
|
|
12
12
|
import { Cron } from 'croner';
|
|
13
13
|
import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
|
|
14
14
|
import { safeJoin } from './paths.js';
|
|
15
|
+
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
15
16
|
import { ALL_AGENT_IDS } from './agents.js';
|
|
17
|
+
import { machineId, normalizeHost } from './machine-id.js';
|
|
16
18
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
17
19
|
export const GITHUB_TRIGGER_EVENTS = [
|
|
18
20
|
'pull_request',
|
|
@@ -40,9 +42,38 @@ export function normalizeTriggerEvent(input) {
|
|
|
40
42
|
};
|
|
41
43
|
return aliases[key] ?? null;
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* True when the job may execute on this machine: no `devices` allowlist (or
|
|
47
|
+
* empty), or the allowlist includes this device. Both sides go through
|
|
48
|
+
* `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
|
|
49
|
+
* `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
|
|
50
|
+
* catchup/overdue, manual run) gates on this.
|
|
51
|
+
*/
|
|
52
|
+
export function jobRunsOnThisDevice(config) {
|
|
53
|
+
if (!config.devices || config.devices.length === 0)
|
|
54
|
+
return true;
|
|
55
|
+
const self = machineId();
|
|
56
|
+
return config.devices.some((d) => normalizeHost(d) === self);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Return null when the job may run here; otherwise return a structured,
|
|
60
|
+
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
61
|
+
* construction so manual run, executeJob, and executeJobDetached stay in
|
|
62
|
+
* sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
|
|
63
|
+
*/
|
|
64
|
+
export function checkJobDeviceEligibility(config) {
|
|
65
|
+
if (jobRunsOnThisDevice(config))
|
|
66
|
+
return null;
|
|
67
|
+
const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
|
|
68
|
+
const allowedLabel = allowed.join(', ');
|
|
69
|
+
const firstHost = allowed[0] ?? 'HOST';
|
|
70
|
+
const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
|
|
71
|
+
const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
|
|
72
|
+
return { message, suggestion, allowedLabel, firstHost };
|
|
73
|
+
}
|
|
43
74
|
/** Default values applied to every job config when fields are omitted. */
|
|
44
75
|
const JOB_DEFAULTS = {
|
|
45
|
-
mode: '
|
|
76
|
+
mode: 'auto',
|
|
46
77
|
effort: 'auto',
|
|
47
78
|
timeout: '10m',
|
|
48
79
|
enabled: true,
|
|
@@ -110,6 +141,11 @@ function readJobFile(filePath) {
|
|
|
110
141
|
const parsed = yaml.parse(content);
|
|
111
142
|
if (!parsed || typeof parsed !== 'object')
|
|
112
143
|
return null;
|
|
144
|
+
// Fail closed on the legacy singular `device` key. A routine that still
|
|
145
|
+
// carries it after v12 startup migration is unmigrated state and must be
|
|
146
|
+
// treated as unavailable/inert rather than unrestricted.
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
|
|
148
|
+
return null;
|
|
113
149
|
return {
|
|
114
150
|
...JOB_DEFAULTS,
|
|
115
151
|
...parsed,
|
|
@@ -120,13 +156,25 @@ function readJobFile(filePath) {
|
|
|
120
156
|
return null;
|
|
121
157
|
}
|
|
122
158
|
}
|
|
123
|
-
/** Write a job config to disk, omitting fields that match defaults.
|
|
159
|
+
/** Write a job config to disk, omitting fields that match defaults.
|
|
160
|
+
*
|
|
161
|
+
* Updates the one existing supported extension (.yml or .yaml) atomically.
|
|
162
|
+
* New routines are written as .yml. If both extensions exist for the same
|
|
163
|
+
* name, the write fails explicitly so we never choose or drop a sibling.
|
|
164
|
+
*/
|
|
124
165
|
export function writeJob(config) {
|
|
125
166
|
ensureAgentsDir();
|
|
126
167
|
const jobsDir = getRoutinesDir();
|
|
127
|
-
const
|
|
168
|
+
const ymlPath = safeJoin(jobsDir, config.name + '.yml');
|
|
169
|
+
const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
|
|
170
|
+
const ymlExists = fs.existsSync(ymlPath);
|
|
171
|
+
const yamlExists = fs.existsSync(yamlPath);
|
|
172
|
+
if (ymlExists && yamlExists) {
|
|
173
|
+
throw new Error(`Routine '${config.name}' has both .yml and .yaml files; resolve the ambiguity before editing.`);
|
|
174
|
+
}
|
|
175
|
+
const filePath = ymlExists ? ymlPath : yamlExists ? yamlPath : ymlPath;
|
|
128
176
|
const output = { ...config };
|
|
129
|
-
if (output.mode === '
|
|
177
|
+
if (output.mode === 'auto')
|
|
130
178
|
delete output.mode;
|
|
131
179
|
if (output.effort === 'auto')
|
|
132
180
|
delete output.effort;
|
|
@@ -136,7 +184,10 @@ export function writeJob(config) {
|
|
|
136
184
|
delete output.enabled;
|
|
137
185
|
if (output.runOnce === false || output.runOnce === undefined)
|
|
138
186
|
delete output.runOnce;
|
|
139
|
-
|
|
187
|
+
const devArr = output.devices;
|
|
188
|
+
if (!devArr || devArr.length === 0)
|
|
189
|
+
delete output.devices;
|
|
190
|
+
atomicWriteFileSync(filePath, yaml.stringify(output));
|
|
140
191
|
}
|
|
141
192
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
142
193
|
export function deleteJob(name) {
|
|
@@ -219,6 +270,22 @@ export function validateJob(config) {
|
|
|
219
270
|
errors.push('endAt must be a parseable ISO 8601 / RFC3339 timestamp (e.g., 2026-12-31T23:59:00Z)');
|
|
220
271
|
}
|
|
221
272
|
}
|
|
273
|
+
if (config.device !== undefined) {
|
|
274
|
+
errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
|
|
275
|
+
}
|
|
276
|
+
if (config.devices !== undefined) {
|
|
277
|
+
if (!Array.isArray(config.devices)) {
|
|
278
|
+
errors.push('devices must be an array of device names (as shown by `agents devices`)');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const d of config.devices) {
|
|
282
|
+
if (typeof d !== 'string' || d.trim() === '') {
|
|
283
|
+
errors.push('each entry in devices must be a non-empty device name');
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
222
289
|
return errors;
|
|
223
290
|
}
|
|
224
291
|
/** Validate a job trigger block, returning a list of human-readable errors. */
|
package/dist/lib/runner.js
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* limit is detected mid-run (foreground `executeJob` only — detached daemon
|
|
14
14
|
* fires once with the pre-flight pick).
|
|
15
15
|
*/
|
|
16
|
-
import { spawn } from 'child_process';
|
|
16
|
+
import { spawn, execFileSync } from 'child_process';
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import * as os from 'os';
|
|
20
|
-
import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './routines.js';
|
|
20
|
+
import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, } from './routines.js';
|
|
21
21
|
import { getRunsDir } from './state.js';
|
|
22
22
|
import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
|
|
23
23
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
@@ -378,6 +378,10 @@ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
|
|
|
378
378
|
* failover across healthy same-agent accounts (RUSH-1016).
|
|
379
379
|
*/
|
|
380
380
|
export async function executeJob(config, deps) {
|
|
381
|
+
const eligibility = checkJobDeviceEligibility(config);
|
|
382
|
+
if (eligibility) {
|
|
383
|
+
throw new Error(eligibility.message);
|
|
384
|
+
}
|
|
381
385
|
maybeRotate();
|
|
382
386
|
const launch = await resolveRoutineLaunch(config);
|
|
383
387
|
const primaryVersion = launch.chain[0]?.version ?? config.version;
|
|
@@ -407,6 +411,7 @@ export async function executeJob(config, deps) {
|
|
|
407
411
|
agent: effectiveAgent,
|
|
408
412
|
...(config.workflow ? { workflow: config.workflow } : {}),
|
|
409
413
|
pid: null,
|
|
414
|
+
spawnedAt: Date.now(),
|
|
410
415
|
status: 'running',
|
|
411
416
|
startedAt: new Date().toISOString(),
|
|
412
417
|
completedAt: null,
|
|
@@ -536,6 +541,11 @@ export async function executeJob(config, deps) {
|
|
|
536
541
|
}
|
|
537
542
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
538
543
|
export async function executeJobDetached(config) {
|
|
544
|
+
const eligibility = checkJobDeviceEligibility(config);
|
|
545
|
+
if (eligibility) {
|
|
546
|
+
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
547
|
+
throw new Error(eligibility.message);
|
|
548
|
+
}
|
|
539
549
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
540
550
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
541
551
|
// wait); the next schedule tick re-selects if this attempt still fails.
|
|
@@ -571,6 +581,7 @@ export async function executeJobDetached(config) {
|
|
|
571
581
|
agent: effectiveAgent,
|
|
572
582
|
...(config.workflow ? { workflow: config.workflow } : {}),
|
|
573
583
|
pid: null,
|
|
584
|
+
spawnedAt: Date.now(),
|
|
574
585
|
status: 'running',
|
|
575
586
|
startedAt: new Date().toISOString(),
|
|
576
587
|
completedAt: null,
|
|
@@ -691,6 +702,45 @@ function inferFinalStatusFromLog(stdoutPath, agent) {
|
|
|
691
702
|
return null;
|
|
692
703
|
}
|
|
693
704
|
}
|
|
705
|
+
const MAX_WALL_CLOCK_MS = 24 * 60 * 60 * 1000;
|
|
706
|
+
/**
|
|
707
|
+
* Verify that a PID still belongs to the process we spawned, not a recycled
|
|
708
|
+
* OS PID. Uses the recorded `spawnedAt` (epoch ms) from meta.json and
|
|
709
|
+
* compares against the process's actual start time via `ps`. Returns true
|
|
710
|
+
* when the PID is alive AND plausibly ours.
|
|
711
|
+
*/
|
|
712
|
+
function isPidOurs(pid, spawnedAt) {
|
|
713
|
+
try {
|
|
714
|
+
process.kill(pid, 0);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
if (spawnedAt === undefined)
|
|
720
|
+
return true;
|
|
721
|
+
if (process.platform === 'win32')
|
|
722
|
+
return true;
|
|
723
|
+
try {
|
|
724
|
+
const etime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
725
|
+
if (!etime)
|
|
726
|
+
return true;
|
|
727
|
+
const parts = etime.replace(/-/g, ':').split(':').reverse();
|
|
728
|
+
let uptimeSec = 0;
|
|
729
|
+
if (parts[0])
|
|
730
|
+
uptimeSec += parseInt(parts[0], 10);
|
|
731
|
+
if (parts[1])
|
|
732
|
+
uptimeSec += parseInt(parts[1], 10) * 60;
|
|
733
|
+
if (parts[2])
|
|
734
|
+
uptimeSec += parseInt(parts[2], 10) * 3600;
|
|
735
|
+
if (parts[3])
|
|
736
|
+
uptimeSec += parseInt(parts[3], 10) * 86400;
|
|
737
|
+
const processStartMs = Date.now() - uptimeSec * 1000;
|
|
738
|
+
return Math.abs(processStartMs - spawnedAt) < 30_000;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
694
744
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
|
695
745
|
export function monitorRunningJobs() {
|
|
696
746
|
const runsDir = getRunsDir();
|
|
@@ -712,14 +762,17 @@ export function monitorRunningJobs() {
|
|
|
712
762
|
continue;
|
|
713
763
|
if (!meta.pid)
|
|
714
764
|
continue;
|
|
715
|
-
|
|
716
|
-
|
|
765
|
+
const runDirPath = path.join(jobRunsPath, runDirEntry.name);
|
|
766
|
+
const stdoutPath = path.join(runDirPath, 'stdout.log');
|
|
767
|
+
const wallClockMs = Date.now() - Date.parse(meta.startedAt);
|
|
768
|
+
if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
|
|
769
|
+
meta.status = 'timeout';
|
|
770
|
+
meta.completedAt = new Date().toISOString();
|
|
771
|
+
writeRunMeta(meta);
|
|
772
|
+
extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
|
|
773
|
+
continue;
|
|
717
774
|
}
|
|
718
|
-
|
|
719
|
-
const runDirPath = path.join(jobRunsPath, runDirEntry.name);
|
|
720
|
-
const stdoutPath = path.join(runDirPath, 'stdout.log');
|
|
721
|
-
// Prefer the agent's own success/error marker; fall back to "failed"
|
|
722
|
-
// only when the stream ended without one (process killed mid-run).
|
|
775
|
+
if (!isPidOurs(meta.pid, meta.spawnedAt)) {
|
|
723
776
|
const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
|
|
724
777
|
if (inferred) {
|
|
725
778
|
meta.status = inferred.status;
|
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
|
}
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* multiple permission modes (plan, edit, full).
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, execSync, execFileSync } from 'child_process';
|
|
11
|
+
import { getAgentsInvocation } from '../daemon.js';
|
|
11
12
|
import * as fs from 'fs/promises';
|
|
12
13
|
import * as fsSync from 'fs';
|
|
13
14
|
import * as path from 'path';
|
|
@@ -1697,12 +1698,11 @@ export class AgentManager {
|
|
|
1697
1698
|
return args;
|
|
1698
1699
|
}
|
|
1699
1700
|
buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
];
|
|
1701
|
+
// Route through getAgentsInvocation so a teammate launched by the compiled
|
|
1702
|
+
// standalone binary (#315) doesn't relaunch as `agents /$bunfs/root/agents …`
|
|
1703
|
+
// (process.argv[1] is the bun virtual entry there) → "unknown command".
|
|
1704
|
+
const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName));
|
|
1705
|
+
const cmd = [inv.command, ...inv.args];
|
|
1706
1706
|
if (cwd)
|
|
1707
1707
|
cmd.push('--cwd', cwd);
|
|
1708
1708
|
// Pin the session UUID to our agent_id so buildExecEnv keys
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* subcommand);
|
|
9
9
|
* - stdout/stderr capture is consistent for the session module to parse.
|
|
10
10
|
*/
|
|
11
|
+
/** Oldest tmux release with `run-shell -C`, required by the managed pane-died hook. */
|
|
12
|
+
export declare const MIN_TMUX_VERSION = "3.2";
|
|
11
13
|
/**
|
|
12
14
|
* Locate the tmux binary on PATH. Cached after first call — tmux either is or
|
|
13
15
|
* isn't installed for the duration of the process.
|
|
@@ -19,6 +21,8 @@ export declare function findTmuxBinary(): string | null;
|
|
|
19
21
|
export declare function isTmuxInstalled(): boolean;
|
|
20
22
|
/** Best-effort tmux version string (e.g. "tmux 3.6a"). Returns null when not installed or version probe fails. */
|
|
21
23
|
export declare function getTmuxVersion(): string | null;
|
|
24
|
+
/** True for a `tmux -V` string at or above the supported 3.2 floor. */
|
|
25
|
+
export declare function isTmuxVersionSupported(version: string | null): boolean;
|
|
22
26
|
/**
|
|
23
27
|
* Throw a user-friendly error when tmux isn't installed. Command handlers call
|
|
24
28
|
* this first thing so the error message is the same shape every time.
|
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';
|