@phnx-labs/agents-cli 1.20.55 → 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 +6 -1
- package/README.md +5 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/message.js +7 -5
- package/dist/commands/profiles.js +4 -3
- package/dist/commands/routines.js +144 -14
- package/dist/index.js +1 -1
- package/dist/lib/daemon.d.ts +19 -1
- package/dist/lib/daemon.js +41 -9
- package/dist/lib/hosts/passthrough.js +8 -4
- package/dist/lib/migrate.d.ts +9 -0
- package/dist/lib/migrate.js +48 -0
- package/dist/lib/routines.d.ts +36 -13
- package/dist/lib/routines.js +61 -11
- package/dist/lib/runner.js +10 -1
- package/dist/lib/teams/agents.js +6 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.56
|
|
6
|
+
|
|
7
|
+
- **Fix native routine schedulers rejecting the published CLI as a Bun virtual path.** Bun's standalone runtime reports the embedded `/$bunfs/root/agents` entry as existing at `process.argv[1]`, while the real physical executable lives at `process.execPath`. Daemon resolution now substitutes that physical executable before generating launchd/systemd manifests or detached launches; the existing virtual-path guard still rejects any virtual path that reaches supervision. Source: `apps/cli/src/lib/daemon.ts`.
|
|
8
|
+
|
|
9
|
+
- **Fix: `agents teams`, `agents message`, and `agents profiles check` work again on the signed standalone binary (regression from #315).** When `agents` resolves to the bun-compiled Mach-O (shipped since 1.20.53), three self-spawn sites relaunched the CLI as `[process.execPath, process.argv[1], …]` — but under a bun standalone executable `process.argv[1]` is the virtual entry `/$bunfs/root/agents`, so the child died with `unknown command '/$bunfs/root/agents'` (or `/bin/sh: /$bunfs/root/agents: No such file or directory`). Every teammate spawned by a compiled-binary install failed in 0s. New shared `getAgentsInvocation(subArgs)` (`apps/cli/src/lib/daemon.ts`) resolves the real on-disk binary — mapping the `/$bunfs/root/…` virtual path to `process.execPath`, running a `.js` entry under node, and a native binary directly — and `teams/agents.ts`, `commands/message.ts`, and `commands/profiles.ts` route through it. Verified end-to-end: a teammate spawned by the freshly-compiled binary runs to `completed` with no `$bunfs` error. Source: `apps/cli/src/lib/daemon.ts` (`getAgentsInvocation`), `apps/cli/src/lib/teams/agents.ts`, `apps/cli/src/commands/{message,profiles}.ts`.
|
|
5
10
|
## 1.20.55
|
|
6
11
|
|
|
7
12
|
- **Routine scheduler health is now observable and self-healing.** `agents routines status` distinguishes `running`, `wedged`, and `stopped`, and reports the daemon binary plus heartbeat age. Routine listing/status opportunistically finalize orphaned runs; PID reuse checks and a 24-hour wall-clock limit prevent stale `running` records; daemon startup rejects bun virtual paths and warns about worktree binaries that can disappear. Source: `apps/cli/src/lib/daemon.ts`, `apps/cli/src/lib/runner.ts`, `apps/cli/src/commands/routines.ts`.
|
|
@@ -10,7 +15,7 @@
|
|
|
10
15
|
- **`agents devices sync` pins the login user on Windows too.** `os.userInfo().username` returns `COMPUTER\user` / `DOMAIN\user` on Windows, which failed the safe-charset guard, so Windows boxes synced with no pinned user and `--host <device>` fell back to the wrong local account. `sanitizeLoginUser` now strips the domain prefix to the bare ssh account before the guard. Also folds the duplicate `user@host` splitter (`parseTarget` in `ssh.ts`) into the canonical `splitUserHost` so there is one parser. Source: `apps/cli/src/lib/devices/sync.ts`, `apps/cli/src/commands/ssh.ts`.
|
|
11
16
|
- **Menu bar ACTIVE section now shows every local session, not just extension-registered terminals.** The dropdown's session source was `live-terminals.json`, which only carries terminals the Factory extension registers — a machine with 25 live sessions (tmux, ghostty, headless) rendered `ACTIVE · 1 running`. The helper now feeds triage + ACTIVE from `agents sessions --active --local --json` (the session engine's authoritative view, issue #741 contract) on the same warm-cache pattern as routines (30s TTL, refreshed off the click path; the cheap file still covers cold start and the 10s badge poll). Blocked sessions outside the extension's view now surface in NEEDS YOU too. Idle rows cap at 3 per repo group — the group header carries the true counts — so a big idle fleet can't wall the menu. Source: `apps/cli/menubar/Sources/MenubarHelper/{StatusItemController,LocalState,AgentsCLI,Models}.swift`.
|
|
12
17
|
- **Daemon service manifests pin JavaScript installs to the current Node runtime.** launchd and systemd now invoke `process.execPath <entry> daemon _run`, matching the detached launcher, instead of executing the JS entrypoint through `#!/usr/bin/env node`. Linux user services therefore stop falling back to an obsolete system Node (observed as Node 18 failing on `node:util.styleText`) when the CLI was installed under Node 22/24. Native `agents` launchers remain direct executables. Source: `apps/cli/src/lib/daemon.ts`.
|
|
13
|
-
- **Routines
|
|
18
|
+
- **Routines support a `devices:` allowlist so multiple machines each fire the same job independently.** Routine YAMLs sync fleet-wide via the user repo, so without a restriction an enabled routine fires on every device running the scheduler. A `devices: [yosemite-s0, mac-mini]` allowlist makes each listed machine run the job independently on schedule; omitting the field (or `--clear`) leaves the job unrestricted. A single-entry list `devices: [yosemite-s0]` replaces the legacy singular `device:` pin — v12 migration converts any existing `device: X` YAML automatically to `devices: [X]`. All automatic paths (cron scheduler, webhook triggers, overdue/`catchup`, daemon nags, detached runner fires, one-shot `--at`) skip devices outside the allowlist; attempting to run a job on an ineligible host errors with the allowed device names and a ready-to-paste `--host` hint. `routines add --devices yosemite-s0,mac-mini` sets the list at creation (validated against the registered fleet); `routines devices <name>` opens a preselected multi-select picker; `--set <csv>` and `--clear` update it non-interactively and are mutually exclusive. `routines list` gains a Devices column; `--json` gains `devices` array and `runsHere`. `--host <device>` (alias: `--device`) routes any `routines` subcommand to a remote machine over SSH. Source: `apps/cli/src/lib/routines.ts`, `apps/cli/src/lib/scheduler.ts`, `apps/cli/src/lib/overdue.ts`, `apps/cli/src/lib/triggers/webhook.ts`, `apps/cli/src/lib/runner.ts`, `apps/cli/src/lib/hosts/passthrough.ts`, `apps/cli/src/lib/migrate.ts`, `apps/cli/src/commands/routines.ts`.
|
|
14
19
|
- **Routines now default to `--mode auto` instead of `plan` (RUSH-1595).** A routine created without an explicit `mode` now runs under the smart classifier (`auto`) rather than read-only `plan`, so unattended jobs can create PRs, write files, and run tests end-to-end without every user opting in — `auto` maps to `--permission-mode auto` (claude), workspace-write + network (codex), `--auto high` (droid), and kimi's default headless run (which had no read-only mode and previously errored at `plan`). Opt down to `mode: plan` for read-only monitoring/reporting. `JOB_DEFAULTS.mode`, the `agents routines add --mode` flag default, and the file-add default all move to `auto`; `writeJob` now omits `mode` when it equals `auto`. Source: `apps/cli/src/lib/routines.ts`, `apps/cli/src/commands/routines.ts`.
|
|
15
20
|
- **`agents publish` — a self-hosted, zero-infrastructure skill registry that round-trips with `agents search`/`agents install`.** Publish walks a git repo's `skills/` directory, records a sha256 of every `SKILL.md`, and writes a flat `skills-index.json` (`SkillIndexDocument` shape) at the repo root, then commits + pushes it and prints the `raw.githubusercontent.com` URL plus the exact `agents registry add skill <name> <url>` command to share. No hosted aggregator: the index is just a file in your GitHub repo, consumed directly by the existing `fetchSkillIndex`/`searchSkillRegistries` path. Targets your `~/.agents` repo by default or an extra repo via `--repo <alias>` (`--dry-run` previews without pushing). Each index entry carries `sha256`, threaded through `SkillEntry`/`normalizeSkillEntry`, and `agents install` now verifies the freshly cloned `SKILL.md` against it — a mismatch aborts with a clear error rather than trusting a tampered artifact. This is the self-hosted/git-index slice of #336; global no-URL discovery (a hosted aggregator) remains future work. Source: `apps/cli/src/commands/packages.ts` (`publish` subcommand + install-time verify), `apps/cli/src/lib/registry.ts` (`buildSkillIndex`, `verifySkillIntegrity`, `sha256OfFile`, `parseOwnerRepoFromRemote`, `SkillIndexEntry.sha256`), `apps/cli/src/lib/types.ts` (`SkillEntry.sha256`). (#336)
|
|
16
21
|
|
package/README.md
CHANGED
|
@@ -643,9 +643,12 @@ agents routines list # All jobs + next run times
|
|
|
643
643
|
agents routines run daily-digest # Test it now, ignore the schedule
|
|
644
644
|
agents routines logs daily-digest # Last execution — status + report (add --full for raw stdout)
|
|
645
645
|
|
|
646
|
-
# Routines sync to every device;
|
|
646
|
+
# Routines sync to every device; restrict to an allowlist with --devices
|
|
647
647
|
agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
|
|
648
|
-
--
|
|
648
|
+
--devices yosemite-s0,mac-mini --prompt "Drain the local work queue"
|
|
649
|
+
|
|
650
|
+
agents routines devices nightly-drain --set yosemite-s0,mac-mini # update allowlist
|
|
651
|
+
agents routines list --host yosemite-s0 # query another device
|
|
649
652
|
```
|
|
650
653
|
|
|
651
654
|
Jobs run sandboxed -- agents only see directories and tools you explicitly allow.
|
package/dist/bin/agents
CHANGED
|
Binary file
|
package/dist/commands/message.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getActiveSessions } from '../lib/session/active.js';
|
|
|
5
5
|
import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
|
|
6
6
|
import { resolveProvider } from '../lib/cloud/registry.js';
|
|
7
7
|
import { mailboxDir, enqueue } from '../lib/mailbox.js';
|
|
8
|
+
import { getAgentsInvocation } from '../lib/daemon.js';
|
|
8
9
|
import { resolveMessageTarget, mailboxIdForActiveSession } from '../lib/mailbox-target.js';
|
|
9
10
|
import { blockIdForSession, listBlocks, readBlock, recordAnswer, recordMessageReceipt, } from '../lib/feed.js';
|
|
10
11
|
import { verifyOperatorIdentity } from '../lib/operator.js';
|
|
@@ -89,11 +90,12 @@ async function deliverViaResume(route, mailboxId) {
|
|
|
89
90
|
die(`Internal error: resume route incomplete for ${mailboxId}.`);
|
|
90
91
|
}
|
|
91
92
|
const argv = resumeArgv(route);
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
const
|
|
93
|
+
// Relaunch the same agents CLI (via getAgentsInvocation, which resolves the
|
|
94
|
+
// real binary — not a bun /$bunfs virtual path under the compiled build) so
|
|
95
|
+
// version pins and wrappers stay consistent. Detach so the resume can take
|
|
96
|
+
// over a TTY when interactive; for feed answers we pass it non-interactively.
|
|
97
|
+
const inv = getAgentsInvocation(argv);
|
|
98
|
+
const child = spawn(inv.command, inv.args, {
|
|
97
99
|
stdio: 'inherit',
|
|
98
100
|
env: process.env,
|
|
99
101
|
});
|
|
@@ -13,6 +13,7 @@ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists,
|
|
|
13
13
|
import { getPreset, listPresets, expandPreset } from '../lib/profiles-presets.js';
|
|
14
14
|
import { hasKeychainToken, keychainItemName, setKeychainToken, deleteKeychainToken, } from '../lib/secrets/profiles.js';
|
|
15
15
|
import { isInteractiveTerminal } from './utils.js';
|
|
16
|
+
import { getAgentsInvocation } from '../lib/daemon.js';
|
|
16
17
|
/**
|
|
17
18
|
* Pure helper: builds a Profile from collected wizard inputs. Extracted so the
|
|
18
19
|
* shape of preset->profile mapping for the `create` wizard is unit-testable
|
|
@@ -280,15 +281,15 @@ Examples:
|
|
|
280
281
|
const run = await confirm({ message: 'Run smoke test now?', default: true });
|
|
281
282
|
if (run) {
|
|
282
283
|
console.log(chalk.gray(`Spawning: agents run ${name} "say alive in one word" (60s timeout)`));
|
|
283
|
-
const
|
|
284
|
-
process.argv[1],
|
|
284
|
+
const inv = getAgentsInvocation([
|
|
285
285
|
'run',
|
|
286
286
|
name,
|
|
287
287
|
'say alive in one word',
|
|
288
288
|
'--headless',
|
|
289
289
|
'--timeout',
|
|
290
290
|
'60s',
|
|
291
|
-
]
|
|
291
|
+
]);
|
|
292
|
+
const child = spawn(inv.command, inv.args, { stdio: 'inherit' });
|
|
292
293
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
293
294
|
}
|
|
294
295
|
else {
|
|
@@ -12,7 +12,7 @@ import * as path from 'path';
|
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
13
|
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
|
|
14
14
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
15
|
-
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, } from '../lib/routines.js';
|
|
15
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, } from '../lib/routines.js';
|
|
16
16
|
import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
|
|
17
17
|
import { getRoutinesDir } from '../lib/state.js';
|
|
18
18
|
import { IS_WINDOWS } from '../lib/platform/index.js';
|
|
@@ -22,6 +22,9 @@ import { JobScheduler } from '../lib/scheduler.js';
|
|
|
22
22
|
import { detectOverdueJobs } from '../lib/overdue.js';
|
|
23
23
|
import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
24
24
|
import { setHelpSections } from '../lib/help.js';
|
|
25
|
+
import { loadDevices } from '../lib/devices/registry.js';
|
|
26
|
+
import { normalizeHost } from '../lib/machine-id.js';
|
|
27
|
+
import { addHostOption } from '../lib/hosts/option.js';
|
|
25
28
|
/**
|
|
26
29
|
* Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
|
|
27
30
|
* when it hasn't completed or timestamps are unparseable. Leading separator lets
|
|
@@ -122,11 +125,34 @@ async function pickJob(message, filter, alternatives = [], cwd) {
|
|
|
122
125
|
throw err;
|
|
123
126
|
}
|
|
124
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse a comma-separated devices string, normalize, deduplicate, and validate
|
|
130
|
+
* each entry against the registered fleet. Exits nonzero on empty/whitespace
|
|
131
|
+
* input or unknown devices.
|
|
132
|
+
*/
|
|
133
|
+
async function parseAndValidateDevices(raw) {
|
|
134
|
+
const names = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean).map((s) => normalizeHost(s)))];
|
|
135
|
+
if (names.length === 0) {
|
|
136
|
+
console.log(chalk.red('--devices requires at least one non-empty device name'));
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const registry = await loadDevices();
|
|
140
|
+
const registered = new Set(Object.keys(registry).map((k) => normalizeHost(k)));
|
|
141
|
+
const unknown = names.filter((n) => !registered.has(n));
|
|
142
|
+
if (unknown.length > 0) {
|
|
143
|
+
console.log(chalk.red(`Unknown device(s): ${unknown.join(', ')}`));
|
|
144
|
+
console.log(chalk.gray(`Registered: ${[...registered].sort().join(', ') || '(none)'}`));
|
|
145
|
+
console.log(chalk.gray('Enroll devices with: agents devices sync'));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
return names;
|
|
149
|
+
}
|
|
125
150
|
/** Register the `agents routines` command tree. */
|
|
126
151
|
export function registerRoutinesCommands(program) {
|
|
127
152
|
const routinesCmd = program
|
|
128
153
|
.command('routines')
|
|
129
154
|
.description('Schedule agents to run on a cron schedule or at a specific time. The scheduler auto-starts on first add.');
|
|
155
|
+
addHostOption(routinesCmd);
|
|
130
156
|
setHelpSections(routinesCmd, {
|
|
131
157
|
examples: `
|
|
132
158
|
# Cron routine: Claude every weekday at 9 AM (scheduler auto-starts)
|
|
@@ -141,6 +167,15 @@ export function registerRoutinesCommands(program) {
|
|
|
141
167
|
# List all routines and their next run times
|
|
142
168
|
agents routines list
|
|
143
169
|
|
|
170
|
+
# List routines on a specific device
|
|
171
|
+
agents routines list --host yosemite-s0
|
|
172
|
+
|
|
173
|
+
# Create a routine restricted to specific devices
|
|
174
|
+
agents routines add nightly --schedule "0 2 * * *" --agent claude --prompt "Summarize today's commits" --devices yosemite-s0,mac-mini
|
|
175
|
+
|
|
176
|
+
# Interactively manage which devices may run a routine
|
|
177
|
+
agents routines devices nightly
|
|
178
|
+
|
|
144
179
|
# Run a routine right now in the foreground (ignores schedule)
|
|
145
180
|
agents routines run daily-standup
|
|
146
181
|
|
|
@@ -216,7 +251,7 @@ export function registerRoutinesCommands(program) {
|
|
|
216
251
|
scheduleHuman: fireConditionLabel(job),
|
|
217
252
|
trigger: job.trigger ?? null,
|
|
218
253
|
timezone: job.timezone ?? null,
|
|
219
|
-
|
|
254
|
+
devices: job.devices ?? [],
|
|
220
255
|
runsHere: jobRunsOnThisDevice(job),
|
|
221
256
|
enabled: job.enabled,
|
|
222
257
|
overdue: overdueSet.has(job.name),
|
|
@@ -240,11 +275,11 @@ export function registerRoutinesCommands(program) {
|
|
|
240
275
|
const NAME_W = 24;
|
|
241
276
|
const AGENT_W = 10;
|
|
242
277
|
const REPO_W = REPO_DISPLAY_MAX;
|
|
243
|
-
const DEVICE_W =
|
|
278
|
+
const DEVICE_W = 22;
|
|
244
279
|
const SCHED_W = 22;
|
|
245
280
|
const ENABLED_W = 10;
|
|
246
281
|
const NEXT_W = 22;
|
|
247
|
-
const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'
|
|
282
|
+
const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Devices'.padEnd(DEVICE_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
|
|
248
283
|
console.log(chalk.gray(header));
|
|
249
284
|
console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
|
|
250
285
|
for (const job of jobs) {
|
|
@@ -268,11 +303,14 @@ export function registerRoutinesCommands(program) {
|
|
|
268
303
|
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
269
304
|
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
270
305
|
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
306
|
+
const deviceFull = job.devices?.join(',') ?? '';
|
|
307
|
+
const deviceWord = deviceFull.length === 0
|
|
308
|
+
? 'all'
|
|
309
|
+
: deviceFull.length > DEVICE_W
|
|
310
|
+
? deviceFull.slice(0, DEVICE_W - 1) + '…'
|
|
311
|
+
: deviceFull;
|
|
312
|
+
const deviceCell = deviceFull.length === 0
|
|
313
|
+
? chalk.gray('all')
|
|
276
314
|
: jobRunsOnThisDevice(job)
|
|
277
315
|
? deviceWord
|
|
278
316
|
: chalk.gray(deviceWord);
|
|
@@ -305,7 +343,7 @@ export function registerRoutinesCommands(program) {
|
|
|
305
343
|
.option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
|
|
306
344
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
307
345
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
308
|
-
.option('--
|
|
346
|
+
.option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
|
|
309
347
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
310
348
|
.option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
|
|
311
349
|
.option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
|
|
@@ -349,6 +387,11 @@ export function registerRoutinesCommands(program) {
|
|
|
349
387
|
console.log(chalk.red('Prompt is required (use --prompt)'));
|
|
350
388
|
process.exit(1);
|
|
351
389
|
}
|
|
390
|
+
// Parse and validate --devices against the fleet registry.
|
|
391
|
+
let devices;
|
|
392
|
+
if (options.devices !== undefined) {
|
|
393
|
+
devices = await parseAndValidateDevices(options.devices);
|
|
394
|
+
}
|
|
352
395
|
const config = {
|
|
353
396
|
name: nameOrPath,
|
|
354
397
|
schedule,
|
|
@@ -360,7 +403,7 @@ export function registerRoutinesCommands(program) {
|
|
|
360
403
|
enabled: !options.disabled,
|
|
361
404
|
prompt: options.prompt,
|
|
362
405
|
timezone: options.timezone,
|
|
363
|
-
...(
|
|
406
|
+
...(devices ? { devices } : {}),
|
|
364
407
|
...(runOnce ? { runOnce: true } : {}),
|
|
365
408
|
...(options.endAt ? { endAt: options.endAt } : {}),
|
|
366
409
|
};
|
|
@@ -562,9 +605,10 @@ export function registerRoutinesCommands(program) {
|
|
|
562
605
|
console.log(chalk.red(`Job '${name}' not found`));
|
|
563
606
|
process.exit(1);
|
|
564
607
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
console.log(chalk.
|
|
608
|
+
const eligibility = checkJobDeviceEligibility(job);
|
|
609
|
+
if (eligibility) {
|
|
610
|
+
console.log(chalk.red(eligibility.message));
|
|
611
|
+
console.log(chalk.gray(` ${eligibility.suggestion}`));
|
|
568
612
|
process.exit(1);
|
|
569
613
|
}
|
|
570
614
|
const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
|
|
@@ -852,6 +896,87 @@ export function registerRoutinesCommands(program) {
|
|
|
852
896
|
process.exit(1);
|
|
853
897
|
}
|
|
854
898
|
});
|
|
899
|
+
// Fleet allowlist management for a single routine.
|
|
900
|
+
routinesCmd
|
|
901
|
+
.command('devices [name]')
|
|
902
|
+
.description('View or change which devices may run a routine. Without flags, opens an interactive picker (requires a TTY).')
|
|
903
|
+
.option('--set <devices>', 'Replace the allowlist with this comma-separated list (strict fleet validation)')
|
|
904
|
+
.option('--clear', 'Remove the allowlist so the routine runs on every device')
|
|
905
|
+
.action(async (name, options) => {
|
|
906
|
+
const hasSet = options.set !== undefined;
|
|
907
|
+
if (hasSet && options.clear) {
|
|
908
|
+
console.log(chalk.red('--set and --clear are mutually exclusive'));
|
|
909
|
+
process.exit(1);
|
|
910
|
+
}
|
|
911
|
+
if (!name) {
|
|
912
|
+
name = await pickJob('Select routine', undefined, ['agents routines devices <name>']) ?? undefined;
|
|
913
|
+
if (!name)
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const job = readJob(name);
|
|
917
|
+
if (!job) {
|
|
918
|
+
console.log(chalk.red(`Job '${name}' not found`));
|
|
919
|
+
process.exit(1);
|
|
920
|
+
}
|
|
921
|
+
if (options.clear) {
|
|
922
|
+
job.devices = undefined;
|
|
923
|
+
writeJob(job);
|
|
924
|
+
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
925
|
+
if (isDaemonRunning())
|
|
926
|
+
signalDaemonReload();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (hasSet) {
|
|
930
|
+
const devices = await parseAndValidateDevices(options.set);
|
|
931
|
+
job.devices = devices;
|
|
932
|
+
writeJob(job);
|
|
933
|
+
console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
|
|
934
|
+
if (isDaemonRunning())
|
|
935
|
+
signalDaemonReload();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// Interactive picker
|
|
939
|
+
if (!isInteractiveTerminal()) {
|
|
940
|
+
requireInteractiveSelection('device allowlist', ['agents routines devices <name> --set a,b', 'agents routines devices <name> --clear']);
|
|
941
|
+
}
|
|
942
|
+
const registry = await loadDevices();
|
|
943
|
+
const registeredNames = Object.keys(registry).map((k) => normalizeHost(k)).sort();
|
|
944
|
+
if (registeredNames.length === 0) {
|
|
945
|
+
console.log(chalk.yellow('No devices registered. Enroll with: agents devices sync'));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const currentSet = new Set((job.devices ?? []).map((d) => normalizeHost(d)));
|
|
949
|
+
try {
|
|
950
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
951
|
+
const selected = await checkbox({
|
|
952
|
+
message: `Devices allowed to run '${name}' (space to toggle, enter to confirm, empty = unrestricted):`,
|
|
953
|
+
choices: registeredNames.map((d) => ({
|
|
954
|
+
value: d,
|
|
955
|
+
name: d,
|
|
956
|
+
checked: currentSet.has(d),
|
|
957
|
+
})),
|
|
958
|
+
});
|
|
959
|
+
if (selected.length === 0) {
|
|
960
|
+
job.devices = undefined;
|
|
961
|
+
writeJob(job);
|
|
962
|
+
console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
job.devices = selected;
|
|
966
|
+
writeJob(job);
|
|
967
|
+
console.log(chalk.green(`Devices for '${name}' set to: ${selected.join(', ')}`));
|
|
968
|
+
}
|
|
969
|
+
if (isDaemonRunning())
|
|
970
|
+
signalDaemonReload();
|
|
971
|
+
}
|
|
972
|
+
catch (err) {
|
|
973
|
+
if (isPromptCancelled(err)) {
|
|
974
|
+
console.log(chalk.gray('Cancelled'));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
throw err;
|
|
978
|
+
}
|
|
979
|
+
});
|
|
855
980
|
// Scheduler lifecycle — usually auto-managed by `routines add`, exposed here for manual control.
|
|
856
981
|
routinesCmd
|
|
857
982
|
.command('start')
|
|
@@ -950,4 +1075,9 @@ export function registerRoutinesCommands(program) {
|
|
|
950
1075
|
console.log(chalk.gray('No scheduler logs'));
|
|
951
1076
|
}
|
|
952
1077
|
});
|
|
1078
|
+
// Every direct routines subcommand accepts the shared --host family so remote
|
|
1079
|
+
// fall-through works and each subcommand's --help documents the flags.
|
|
1080
|
+
for (const sub of routinesCmd.commands) {
|
|
1081
|
+
addHostOption(sub);
|
|
1082
|
+
}
|
|
953
1083
|
}
|
package/dist/index.js
CHANGED
|
@@ -874,7 +874,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
|
|
|
874
874
|
// Bumping the suffix re-runs migrations for every user; binary releases that
|
|
875
875
|
// don't change the schema must NOT re-run (they would destroy user content
|
|
876
876
|
// when migration steps overlap with user-authored paths). See issue #20.
|
|
877
|
-
const sentinelValue = '
|
|
877
|
+
const sentinelValue = 'v12';
|
|
878
878
|
let needRun = true;
|
|
879
879
|
try {
|
|
880
880
|
if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export declare function writeOwnerOnlyServiceManifest(filePath: string, content:
|
|
|
85
85
|
export declare function generateLaunchdPlist(oauthToken?: string | null): string;
|
|
86
86
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
87
87
|
export declare function generateSystemdUnit(oauthToken?: string | null): string;
|
|
88
|
-
export declare function getAgentsBinPath(): string;
|
|
88
|
+
export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
|
|
89
89
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
90
90
|
export declare function startDaemon(): {
|
|
91
91
|
pid: number | null;
|
|
@@ -136,6 +136,24 @@ export declare function getDaemonLaunch(agentsBin?: string): {
|
|
|
136
136
|
command: string;
|
|
137
137
|
args: string[];
|
|
138
138
|
};
|
|
139
|
+
/**
|
|
140
|
+
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
141
|
+
*
|
|
142
|
+
* Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
|
|
143
|
+
* `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
|
|
144
|
+
* directly (`<bin> …`).
|
|
145
|
+
*
|
|
146
|
+
* Callers MUST route self-spawns through this rather than hand-rolling
|
|
147
|
+
* `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
|
|
148
|
+
* (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
|
|
149
|
+
* hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
|
|
150
|
+
* bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
|
|
151
|
+
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
152
|
+
*/
|
|
153
|
+
export declare function getAgentsInvocation(subArgs: string[], agentsBin?: string): {
|
|
154
|
+
command: string;
|
|
155
|
+
args: string[];
|
|
156
|
+
};
|
|
139
157
|
export declare function validateDaemonBinary(binPath: string): {
|
|
140
158
|
warnings: string[];
|
|
141
159
|
};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -712,28 +712,39 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node
|
|
|
712
712
|
[Install]
|
|
713
713
|
WantedBy=default.target`;
|
|
714
714
|
}
|
|
715
|
-
|
|
715
|
+
const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
|
|
716
|
+
function resolveBunStandaloneEntry(entry, execPath) {
|
|
717
|
+
if (!BUN_VIRTUAL_ROOT.test(entry))
|
|
718
|
+
return entry;
|
|
719
|
+
if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
|
|
720
|
+
throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
|
|
721
|
+
}
|
|
722
|
+
return execPath;
|
|
723
|
+
}
|
|
724
|
+
export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
|
|
716
725
|
// Prefer the binary actively executing this code. `which agents` returns
|
|
717
726
|
// whatever happens to be first on PATH, which means a side-by-side dev
|
|
718
727
|
// build at ~/.local/bin would silently spawn the registry-installed
|
|
719
|
-
// daemon and run stale code. process.argv[1] is the
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
728
|
+
// daemon and run stale code. For a JS install, process.argv[1] is the
|
|
729
|
+
// absolute entrypoint the user actually invoked. A Bun standalone instead
|
|
730
|
+
// exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
|
|
731
|
+
// executable at process.execPath; Bun reports both as existing paths.
|
|
732
|
+
const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
|
|
733
|
+
if (runningEntry && fs.existsSync(runningEntry)) {
|
|
723
734
|
// The package's browser/computer entrypoints are sibling shims without a
|
|
724
735
|
// `daemon` command. A daemon started as their IPC side effect must launch
|
|
725
736
|
// through the main agents entrypoint instead of replaying the shim path.
|
|
726
|
-
const entryName = path.basename(
|
|
737
|
+
const entryName = path.basename(runningEntry);
|
|
727
738
|
const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
|
|
728
739
|
const installedShim = /^(browser|computer)$/.test(entryName);
|
|
729
740
|
if (compiledShim || installedShim) {
|
|
730
|
-
const agentsEntry = path.join(path.dirname(
|
|
741
|
+
const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
|
|
731
742
|
if (!fs.existsSync(agentsEntry)) {
|
|
732
743
|
throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
|
|
733
744
|
}
|
|
734
745
|
return agentsEntry;
|
|
735
746
|
}
|
|
736
|
-
return
|
|
747
|
+
return runningEntry;
|
|
737
748
|
}
|
|
738
749
|
try {
|
|
739
750
|
return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
@@ -903,9 +914,30 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
|
903
914
|
}
|
|
904
915
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
905
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
919
|
+
*
|
|
920
|
+
* Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
|
|
921
|
+
* `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
|
|
922
|
+
* directly (`<bin> …`).
|
|
923
|
+
*
|
|
924
|
+
* Callers MUST route self-spawns through this rather than hand-rolling
|
|
925
|
+
* `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
|
|
926
|
+
* (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
|
|
927
|
+
* hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
|
|
928
|
+
* bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
|
|
929
|
+
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
930
|
+
*/
|
|
931
|
+
export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
|
|
932
|
+
const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
|
|
933
|
+
if (/\.(c|m)?js$/.test(resolvedBin)) {
|
|
934
|
+
return { command: process.execPath, args: [resolvedBin, ...subArgs] };
|
|
935
|
+
}
|
|
936
|
+
return { command: resolvedBin, args: subArgs };
|
|
937
|
+
}
|
|
906
938
|
export function validateDaemonBinary(binPath) {
|
|
907
939
|
const warnings = [];
|
|
908
|
-
if (
|
|
940
|
+
if (BUN_VIRTUAL_ROOT.test(binPath)) {
|
|
909
941
|
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
910
942
|
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
911
943
|
}
|
|
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
|
|
|
34
34
|
sync: { nonInteractive: ['--yes'] },
|
|
35
35
|
teams: {},
|
|
36
36
|
message: {},
|
|
37
|
+
routines: {},
|
|
37
38
|
};
|
|
38
39
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
39
40
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
@@ -115,10 +116,13 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
115
116
|
process.exitCode = 1;
|
|
116
117
|
return true;
|
|
117
118
|
}
|
|
118
|
-
// `--
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// `--hosts` is always a generic fleet flag — bail for every command so the
|
|
120
|
+
// local aggregator handles it. `--devices` is fan-out on most commands but
|
|
121
|
+
// a placement flag on `routines` (which devices may run the routine), so
|
|
122
|
+
// only exempt routines from the bail.
|
|
123
|
+
if (allArgs.includes('--hosts'))
|
|
124
|
+
return false;
|
|
125
|
+
if (allArgs.includes('--devices') && command !== 'routines')
|
|
122
126
|
return false;
|
|
123
127
|
const hostName = hostFlag ?? deviceFlag;
|
|
124
128
|
if (!hostName)
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -96,5 +96,14 @@ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shi
|
|
|
96
96
|
* the new name.
|
|
97
97
|
*/
|
|
98
98
|
export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
101
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
102
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
103
|
+
* is left untouched.
|
|
104
|
+
*
|
|
105
|
+
* Params default to the real routines dir; injectable for tests.
|
|
106
|
+
*/
|
|
107
|
+
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
99
108
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
100
109
|
export declare function runMigration(): Promise<void>;
|
package/dist/lib/migrate.js
CHANGED
|
@@ -1885,6 +1885,52 @@ export function migrateExtrasExtrasToAgentsExtras(historyDir = HISTORY_DIR) {
|
|
|
1885
1885
|
console.error(`Renamed extras-extras → agents-extras (dirs: ${renamedDirs}, known_marketplaces: ${rewroteKnown}, settings: ${rewroteSettings})`);
|
|
1886
1886
|
}
|
|
1887
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
1890
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
1891
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
1892
|
+
* is left untouched.
|
|
1893
|
+
*
|
|
1894
|
+
* Params default to the real routines dir; injectable for tests.
|
|
1895
|
+
*/
|
|
1896
|
+
export function migrateRoutineDeviceToDevices(routinesDir) {
|
|
1897
|
+
const dir = routinesDir ?? path.join(USER_DIR, 'routines');
|
|
1898
|
+
if (!fs.existsSync(dir))
|
|
1899
|
+
return;
|
|
1900
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
1901
|
+
let migrated = 0;
|
|
1902
|
+
for (const file of files) {
|
|
1903
|
+
const filePath = path.join(dir, file);
|
|
1904
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
1905
|
+
let doc;
|
|
1906
|
+
try {
|
|
1907
|
+
doc = yaml.parse(raw);
|
|
1908
|
+
if (!doc || typeof doc !== 'object')
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
if (!('device' in doc))
|
|
1915
|
+
continue;
|
|
1916
|
+
if ('devices' in doc) {
|
|
1917
|
+
delete doc.device;
|
|
1918
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
const val = doc.device;
|
|
1922
|
+
if (typeof val !== 'string' || !val.trim()) {
|
|
1923
|
+
throw new Error(`${file}: legacy 'device' field is not a valid device name — repair the file and retry`);
|
|
1924
|
+
}
|
|
1925
|
+
delete doc.device;
|
|
1926
|
+
doc.devices = [val.trim()];
|
|
1927
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1928
|
+
migrated++;
|
|
1929
|
+
}
|
|
1930
|
+
if (migrated > 0) {
|
|
1931
|
+
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1888
1934
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
1889
1935
|
export async function runMigration() {
|
|
1890
1936
|
// MUST run first: every other migrator reads SYSTEM_DIR (the new path).
|
|
@@ -1937,6 +1983,8 @@ export async function runMigration() {
|
|
|
1937
1983
|
// installed version-home. Runs after migrateRuntimeToHistory so the version
|
|
1938
1984
|
// homes are at their canonical HISTORY_DIR location.
|
|
1939
1985
|
migrateExtrasExtrasToAgentsExtras();
|
|
1986
|
+
// Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
|
|
1987
|
+
migrateRoutineDeviceToDevices();
|
|
1940
1988
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
1941
1989
|
repairAgentConfigSymlinks();
|
|
1942
1990
|
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -61,14 +61,13 @@ export interface JobConfig {
|
|
|
61
61
|
timezone?: string;
|
|
62
62
|
repo?: string;
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
* inert and `run` refuses with an `agents ssh` pointer.
|
|
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.
|
|
70
69
|
*/
|
|
71
|
-
|
|
70
|
+
devices?: string[];
|
|
72
71
|
variables?: Record<string, string>;
|
|
73
72
|
sandbox?: boolean;
|
|
74
73
|
allow?: JobAllowConfig;
|
|
@@ -94,12 +93,31 @@ export interface RunMeta {
|
|
|
94
93
|
exitCode: number | null;
|
|
95
94
|
}
|
|
96
95
|
/**
|
|
97
|
-
* True when the job may execute on this machine: no `
|
|
98
|
-
*
|
|
99
|
-
* `yosemite-s0.tailnet.ts.net`, and
|
|
100
|
-
* (cron scheduler, webhook,
|
|
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
101
|
*/
|
|
102
|
-
export declare function jobRunsOnThisDevice(config: Pick<JobConfig, '
|
|
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;
|
|
103
121
|
/**
|
|
104
122
|
* List all job configs, scanning project > user routine dirs.
|
|
105
123
|
* Project routines (`<project>/.agents/routines/`) shadow user routines of the
|
|
@@ -113,7 +131,12 @@ export declare function listJobs(cwd?: string): JobConfig[];
|
|
|
113
131
|
* only resolve user routines.
|
|
114
132
|
*/
|
|
115
133
|
export declare function readJob(name: string, cwd?: string): JobConfig | null;
|
|
116
|
-
/** 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
|
+
*/
|
|
117
140
|
export declare function writeJob(config: JobConfig): void;
|
|
118
141
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
119
142
|
export declare function deleteJob(name: string): boolean;
|
package/dist/lib/routines.js
CHANGED
|
@@ -12,6 +12,7 @@ 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';
|
|
16
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
17
18
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
@@ -42,15 +43,33 @@ export function normalizeTriggerEvent(input) {
|
|
|
42
43
|
return aliases[key] ?? null;
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
|
-
* True when the job may execute on this machine: no `
|
|
46
|
-
*
|
|
47
|
-
* `yosemite-s0.tailnet.ts.net`, and
|
|
48
|
-
* (cron scheduler, webhook,
|
|
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.
|
|
49
51
|
*/
|
|
50
52
|
export function jobRunsOnThisDevice(config) {
|
|
51
|
-
if (!config.
|
|
53
|
+
if (!config.devices || config.devices.length === 0)
|
|
52
54
|
return true;
|
|
53
|
-
|
|
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 };
|
|
54
73
|
}
|
|
55
74
|
/** Default values applied to every job config when fields are omitted. */
|
|
56
75
|
const JOB_DEFAULTS = {
|
|
@@ -122,6 +141,11 @@ function readJobFile(filePath) {
|
|
|
122
141
|
const parsed = yaml.parse(content);
|
|
123
142
|
if (!parsed || typeof parsed !== 'object')
|
|
124
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;
|
|
125
149
|
return {
|
|
126
150
|
...JOB_DEFAULTS,
|
|
127
151
|
...parsed,
|
|
@@ -132,11 +156,23 @@ function readJobFile(filePath) {
|
|
|
132
156
|
return null;
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
|
-
/** 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
|
+
*/
|
|
136
165
|
export function writeJob(config) {
|
|
137
166
|
ensureAgentsDir();
|
|
138
167
|
const jobsDir = getRoutinesDir();
|
|
139
|
-
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;
|
|
140
176
|
const output = { ...config };
|
|
141
177
|
if (output.mode === 'auto')
|
|
142
178
|
delete output.mode;
|
|
@@ -148,7 +184,10 @@ export function writeJob(config) {
|
|
|
148
184
|
delete output.enabled;
|
|
149
185
|
if (output.runOnce === false || output.runOnce === undefined)
|
|
150
186
|
delete output.runOnce;
|
|
151
|
-
|
|
187
|
+
const devArr = output.devices;
|
|
188
|
+
if (!devArr || devArr.length === 0)
|
|
189
|
+
delete output.devices;
|
|
190
|
+
atomicWriteFileSync(filePath, yaml.stringify(output));
|
|
152
191
|
}
|
|
153
192
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
154
193
|
export function deleteJob(name) {
|
|
@@ -232,8 +271,19 @@ export function validateJob(config) {
|
|
|
232
271
|
}
|
|
233
272
|
}
|
|
234
273
|
if (config.device !== undefined) {
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
}
|
|
237
287
|
}
|
|
238
288
|
}
|
|
239
289
|
return errors;
|
package/dist/lib/runner.js
CHANGED
|
@@ -17,7 +17,7 @@ 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;
|
|
@@ -537,6 +541,11 @@ export async function executeJob(config, deps) {
|
|
|
537
541
|
}
|
|
538
542
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
539
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
|
+
}
|
|
540
549
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
541
550
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
542
551
|
// wait); the next schedule tick re-selects if this attempt still fails.
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.56",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|