@phnx-labs/agents-cli 1.20.54 → 1.20.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -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/ssh.js +2 -9
- 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/sync.d.ts +7 -0
- package/dist/lib/devices/sync.js +13 -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/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
|
@@ -11,6 +11,13 @@ import type { PendingDevice } from './pending.js';
|
|
|
11
11
|
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
12
12
|
* identifier, so a weird value never lands in the registry. */
|
|
13
13
|
export declare function localLoginUser(): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
|
|
16
|
+
* the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
|
|
17
|
+
* after the backslash — without this strip the `\` fails the charset guard and
|
|
18
|
+
* Windows boxes never pin a user. Pure, so the platform-specific munging is
|
|
19
|
+
* unit-tested without reading the real OS user. */
|
|
20
|
+
export declare function sanitizeLoginUser(raw: string | undefined): string | undefined;
|
|
14
21
|
/**
|
|
15
22
|
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
16
23
|
* the user pinned. Precedence: an existing registered user wins; else the local
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -34,7 +34,19 @@ export function localLoginUser() {
|
|
|
34
34
|
catch {
|
|
35
35
|
u = process.env.USER || process.env.USERNAME || undefined;
|
|
36
36
|
}
|
|
37
|
-
return
|
|
37
|
+
return sanitizeLoginUser(u);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
|
|
41
|
+
* the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
|
|
42
|
+
* after the backslash — without this strip the `\` fails the charset guard and
|
|
43
|
+
* Windows boxes never pin a user. Pure, so the platform-specific munging is
|
|
44
|
+
* unit-tested without reading the real OS user. */
|
|
45
|
+
export function sanitizeLoginUser(raw) {
|
|
46
|
+
if (!raw)
|
|
47
|
+
return undefined;
|
|
48
|
+
const bare = raw.includes('\\') ? raw.slice(raw.lastIndexOf('\\') + 1) : raw;
|
|
49
|
+
return /^[a-zA-Z0-9._-]+$/.test(bare) ? bare : undefined;
|
|
38
50
|
}
|
|
39
51
|
/**
|
|
40
52
|
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
package/dist/lib/exec.js
CHANGED
|
@@ -927,14 +927,16 @@ async function runInTmux(options, executable, args) {
|
|
|
927
927
|
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
928
928
|
// survives just long enough to read the dead pane's exit status below. The
|
|
929
929
|
// `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
|
|
930
|
-
// the window and exits one of THEIR panes, the else-branch
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
//
|
|
937
|
-
|
|
930
|
+
// the window and exits one of THEIR panes, the else-branch closes that split
|
|
931
|
+
// in place instead of detaching everyone (`run-shell -C` executes the
|
|
932
|
+
// targeted kill inside tmux's server command queue, avoiding a second
|
|
933
|
+
// client racing the same socket under load, #965). Without the guard,
|
|
934
|
+
// exiting any split kicked the user clean out of tmux.
|
|
935
|
+
const hookInstalled = await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
|
|
936
|
+
// Stamp the schema marker only after tmux accepted the hook. A failed
|
|
937
|
+
// install stays unmarked so daemon reconciliation retries it later.
|
|
938
|
+
if (hookInstalled)
|
|
939
|
+
await markSessionHookSchema(name, socket);
|
|
938
940
|
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
939
941
|
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
940
942
|
let panePid = 0;
|
|
Binary file
|
package/dist/lib/overdue.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { Cron } from 'croner';
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import { spawn } from 'child_process';
|
|
17
|
-
import { listJobs, getLatestRun } from './routines.js';
|
|
17
|
+
import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
|
|
18
18
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
19
19
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
20
20
|
const GRACE_MS = 60_000;
|
|
@@ -47,6 +47,11 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
47
47
|
// Trigger-only jobs (no cron schedule) never have an expected fire time.
|
|
48
48
|
if (!job.schedule)
|
|
49
49
|
continue;
|
|
50
|
+
// A job pinned to another device is that device's to run, notify, and
|
|
51
|
+
// catch up — flagging it here would make every machine in the fleet nag
|
|
52
|
+
// (and `catchup` fire) for a job that must not run locally.
|
|
53
|
+
if (!jobRunsOnThisDevice(job))
|
|
54
|
+
continue;
|
|
50
55
|
let expected = null;
|
|
51
56
|
try {
|
|
52
57
|
const cronOptions = { paused: true };
|
|
@@ -88,6 +88,58 @@ export const PRESETS = [
|
|
|
88
88
|
ANTHROPIC_SMALL_FAST_MODEL: 'deepseek/deepseek-chat-v3-0324',
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'open-claude',
|
|
93
|
+
description: 'Open-weight coding via OpenRouter inside Claude Code (Qwen3 Coder Next, 256K ctx, $0.15/$0.80 per 1M). HEADLESS-SAFE — best general preset for open-claude usage with Claude Code harness.',
|
|
94
|
+
...OPENROUTER_AUTH,
|
|
95
|
+
env: {
|
|
96
|
+
ANTHROPIC_BASE_URL: OPENROUTER_BASE,
|
|
97
|
+
ANTHROPIC_MODEL: 'qwen/qwen3-coder-next',
|
|
98
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'qwen/qwen3-coder-next',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'claude-spark',
|
|
103
|
+
description: 'Meta Claude Spark 1.1 via OpenRouter inside Claude Code (open alternative). Model: meta/claude-spark-1.1 — free via opencode, now usable in Claude Code UI. HEADLESS-SAFE. For open-claude spark usage.',
|
|
104
|
+
...OPENROUTER_AUTH,
|
|
105
|
+
env: {
|
|
106
|
+
ANTHROPIC_BASE_URL: OPENROUTER_BASE,
|
|
107
|
+
ANTHROPIC_MODEL: 'meta/claude-spark-1.1',
|
|
108
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'meta/claude-spark-1.1',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// ----- OpenCode CLI (open-claude harness) -----
|
|
112
|
+
{
|
|
113
|
+
name: 'opencode',
|
|
114
|
+
description: 'OpenCode default — uses your configured model via opencode auth. Run `opencode auth` to login, then `agents run opencode --model meta/claude-spark-1.1 "prompt"` for spark usage.',
|
|
115
|
+
provider: 'opencode',
|
|
116
|
+
host: 'opencode',
|
|
117
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
118
|
+
authOptional: true,
|
|
119
|
+
env: {},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'opencode-spark',
|
|
123
|
+
description: 'Meta Claude Spark 1.1 via OpenCode (free, headless-safe). Pinned to meta/claude-spark-1.1 — best for open-claude usage with opencode harness.',
|
|
124
|
+
provider: 'opencode',
|
|
125
|
+
host: 'opencode',
|
|
126
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
127
|
+
authOptional: true,
|
|
128
|
+
env: {
|
|
129
|
+
OPENCODE_MODEL: 'meta/claude-spark-1.1',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'opencode-qwen',
|
|
134
|
+
description: 'Qwen3 Coder Next via OpenCode (open-claude path). Use `agents run opencode-qwen "prompt"` — free via opencode provider.',
|
|
135
|
+
provider: 'opencode',
|
|
136
|
+
host: 'opencode',
|
|
137
|
+
authEnvVar: 'OPENCODE_API_KEY',
|
|
138
|
+
authOptional: true,
|
|
139
|
+
env: {
|
|
140
|
+
OPENCODE_MODEL: 'qwen/qwen3-coder-next',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
91
143
|
// ----- xAI Grok Build CLI (native host) -----
|
|
92
144
|
{
|
|
93
145
|
name: 'grok-fast',
|
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,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
|
}
|
|
@@ -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';
|