@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.55
|
|
6
|
+
|
|
7
|
+
- **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`.
|
|
8
|
+
- **Built-in Open-Claude and OpenCode profiles.** `agents profiles` now ships `open-claude` and `claude-spark` for Claude Code through OpenRouter, plus `opencode`, `opencode-spark`, and `opencode-qwen` presets for the OpenCode harness. Source: `apps/cli/src/lib/profiles-presets.ts`, `apps/cli/docs/profiles.md`.
|
|
9
|
+
- **Fix: exiting a user split inside an `agents run` tmux session reliably closes just that split (de-flakes CI #965).** The guarded `pane-died` hook's else-branch was a bare `kill-pane`, which relies on the hook context supplying an implicit "current pane" — nondeterministic on a loaded detached server, so the dead split intermittently survived as a husk (the same failure the flaky `session.test.ts` pane-died tests reproduced in CI). An intermediate external `tmux -S <socket>` self-client still raced the server under Linux load. The else-branch now runs `run-shell -C "kill-pane -t #{hook_pane}"`, which format-expands the event pane and executes the targeted command inside tmux's own server queue. Interactive tmux-backed runs now require tmux 3.2+, the release that introduced `run-shell -C`. `AGENT_HOOK_SCHEMA` bumps to 4; the daemon reconcile retrofits live sessions automatically and only stamps the marker after tmux accepts the hook, so a transient failure stays retryable. Source: `apps/cli/src/lib/tmux/session.ts` (`agentPaneDiedHook`, `AGENT_HOOK_SCHEMA`), `apps/cli/src/lib/tmux/binary.ts`, `apps/cli/src/lib/exec.ts`.
|
|
10
|
+
- **`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
|
+
- **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
|
+
- **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 can be pinned to one machine with `device:` / `--device`.** Routine YAMLs sync fleet-wide via the user repo, so an enabled routine previously fired on every device running the scheduler. A `device: <name>` pin (matched against the local `machineId()`, normalized like `agents devices` names) makes the job inert everywhere else: the cron scheduler skips it, webhook triggers don't match it, overdue detection/`catchup`/daemon nags ignore it, and `routines run` refuses with an `agents ssh <device>` pointer. `routines list` grows a Device column and `--json` gains `device` + `runsHere`. 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/commands/routines.ts`.
|
|
14
|
+
- **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
|
+
- **`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
|
+
|
|
5
17
|
## 1.20.54
|
|
6
18
|
|
|
7
19
|
- **Unified fleet target resolution for `agents ssh` + `sessions --host`.** `agents ssh` now accepts the full target grammar the fan-out already used — a registered `name`, a `user@device` (same device, login user overridden, still dialed via its Tailscale route rather than raw LAN DNS), and an ad-hoc `user@host`/`host` literal — instead of only an exact device name (`agents ssh muqsit@mac-mini` no longer errors "Unknown device"). A bare unregistered alias still reports "Unknown device". `sessions --host user@device` now resolves the host part through the registry too, so it stops silently diverging onto the non-Tailscale route. New `resolveDeviceTarget`; `resolveSshTarget` shares one host-part matcher. Source: `apps/cli/src/lib/devices/resolve-target.ts`, `apps/cli/src/commands/ssh.ts`.
|
package/README.md
CHANGED
|
@@ -642,6 +642,10 @@ agents routines add daily-digest \
|
|
|
642
642
|
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
|
+
|
|
646
|
+
# Routines sync to every device; pin one to a single machine with --device
|
|
647
|
+
agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
|
|
648
|
+
--device yosemite-s0 --prompt "Drain the work queue"
|
|
645
649
|
```
|
|
646
650
|
|
|
647
651
|
Jobs run sandboxed -- agents only see directories and tools you explicitly allow.
|
|
@@ -895,6 +899,8 @@ macOS and Linux. Windows via WSL works but isn't first-class yet.
|
|
|
895
899
|
|
|
896
900
|
**macOS-only features:** Keychain-based secrets (`agents secrets`, `agents profiles login`) require macOS. Default iCloud sync for bundles requires macOS + iCloud Keychain enabled; use `--no-icloud-sync` for device-local bundles. On Linux, use environment variables or `.env` files for API keys. Native Linux credential store support is planned.
|
|
897
901
|
|
|
902
|
+
Interactive tmux-backed runs require tmux 3.2 or newer.
|
|
903
|
+
|
|
898
904
|
### Do I need Node.js?
|
|
899
905
|
|
|
900
906
|
The installer tries Bun first (faster), falls back to npm. Node 22.5+ required at runtime.
|
package/dist/bin/agents
CHANGED
|
Binary file
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* for discovering and installing MCP servers, skills, commands, and
|
|
6
6
|
* hooks from configured registries or GitHub sources.
|
|
7
7
|
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
8
10
|
import chalk from 'chalk';
|
|
9
11
|
import ora from 'ora';
|
|
12
|
+
import { getAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
|
|
10
13
|
import { AGENTS, ALL_AGENT_IDS, getAllCliStates, agentLabel, } from '../lib/agents.js';
|
|
11
14
|
import { capableAgents } from '../lib/capabilities.js';
|
|
12
15
|
import { DEFAULT_REGISTRIES } from '../lib/types.js';
|
|
13
|
-
import { getRegistries, setRegistry, removeRegistry, search as searchRegistries, resolvePackage, validatedNpmSpec, validatedPyPISpec, } from '../lib/registry.js';
|
|
14
|
-
import { cloneRepo } from '../lib/git.js';
|
|
16
|
+
import { getRegistries, setRegistry, removeRegistry, search as searchRegistries, resolvePackage, validatedNpmSpec, validatedPyPISpec, buildSkillIndex, verifySkillIntegrity, parseOwnerRepoFromRemote, } from '../lib/registry.js';
|
|
17
|
+
import { cloneRepo, commitAndPush, getRemoteUrl, isGitRepo } from '../lib/git.js';
|
|
15
18
|
import { discoverCommands, resolveCommandSource, installCommand, installCommandCentrally, } from '../lib/commands.js';
|
|
16
19
|
import { discoverSkillsFromRepo, installSkill, installSkillCentrally, } from '../lib/skills.js';
|
|
17
20
|
import { discoverHooksFromRepo, installHooks, installHooksCentrally, } from '../lib/hooks.js';
|
|
@@ -325,6 +328,100 @@ When to use:
|
|
|
325
328
|
}
|
|
326
329
|
});
|
|
327
330
|
// ==========================================================================
|
|
331
|
+
// PUBLISH COMMAND (self-hosted, git-index skill registry)
|
|
332
|
+
// ==========================================================================
|
|
333
|
+
program
|
|
334
|
+
.command('publish')
|
|
335
|
+
.description('Generate a skills-index.json for a git repo and push it, making its skills discoverable via agents search/install')
|
|
336
|
+
.option('--repo <alias>', 'Publish an extra repo added via `agents repo add` (default: your ~/.agents repo)')
|
|
337
|
+
.option('--name <name>', 'Registry name to suggest in the output (default: the repo name)')
|
|
338
|
+
.option('--branch <branch>', 'Branch the raw URL should reference', 'main')
|
|
339
|
+
.option('--dry-run', 'Write the index and print the URL without committing or pushing')
|
|
340
|
+
.addHelpText('after', `
|
|
341
|
+
Publish walks a repo's skills/ directory, records a sha256 of every SKILL.md,
|
|
342
|
+
and writes skills-index.json at the repo root — a flat index that 'agents search'
|
|
343
|
+
and 'agents install' can consume directly from raw.githubusercontent.com. No
|
|
344
|
+
hosted infrastructure: the index is just a file committed to your GitHub repo.
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
# Publish your ~/.agents repo's skills
|
|
348
|
+
agents publish
|
|
349
|
+
|
|
350
|
+
# Publish an extra repo added via 'agents repo add'
|
|
351
|
+
agents publish --repo team-skills
|
|
352
|
+
|
|
353
|
+
# Preview the index without pushing
|
|
354
|
+
agents publish --dry-run
|
|
355
|
+
|
|
356
|
+
After publishing, share the printed 'agents registry add skill ...' command so
|
|
357
|
+
others can search and install your skills. Installs verify each SKILL.md against
|
|
358
|
+
the sha256 in the index and abort on mismatch.
|
|
359
|
+
`)
|
|
360
|
+
.action(async (options) => {
|
|
361
|
+
// Resolve the target repo: an extra repo by alias, else the primary ~/.agents repo.
|
|
362
|
+
let repoDir;
|
|
363
|
+
if (options.repo) {
|
|
364
|
+
const extra = getEnabledExtraRepos().find((r) => r.alias === options.repo);
|
|
365
|
+
if (!extra) {
|
|
366
|
+
console.log(chalk.red(`No enabled extra repo aliased '${options.repo}'.`));
|
|
367
|
+
console.log(chalk.gray('Add one with: agents repo add <source> --as <alias>'));
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
repoDir = extra.dir;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
repoDir = getAgentsDir();
|
|
374
|
+
}
|
|
375
|
+
if (!isGitRepo(repoDir)) {
|
|
376
|
+
console.log(chalk.red(`${repoDir} is not a git repository.`));
|
|
377
|
+
console.log(chalk.gray('publish commits + pushes the index, so the repo needs a GitHub remote.'));
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
const remoteUrl = await getRemoteUrl(repoDir);
|
|
381
|
+
const repoSlug = remoteUrl ? parseOwnerRepoFromRemote(remoteUrl) : null;
|
|
382
|
+
if (!repoSlug) {
|
|
383
|
+
console.log(chalk.red(`Could not resolve an 'owner/repo' from the git remote of ${repoDir}.`));
|
|
384
|
+
console.log(chalk.gray(` Remote: ${remoteUrl || '(none)'} — publish needs a GitHub origin.`));
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
const spinner = ora('Building skills index...').start();
|
|
388
|
+
const index = buildSkillIndex(repoDir, repoSlug, { generatedAt: new Date().toISOString() });
|
|
389
|
+
spinner.stop();
|
|
390
|
+
if (index.skills.length === 0) {
|
|
391
|
+
console.log(chalk.yellow(`No skills found under ${repoDir}/skills.`));
|
|
392
|
+
console.log(chalk.gray('Add a skill (a directory with a SKILL.md) and re-run publish.'));
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
const indexPath = path.join(repoDir, 'skills-index.json');
|
|
396
|
+
fs.writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n', 'utf-8');
|
|
397
|
+
console.log(chalk.bold(`\nIndexed ${index.skills.length} skill(s) into skills-index.json:`));
|
|
398
|
+
for (const s of index.skills) {
|
|
399
|
+
console.log(` ${chalk.cyan(s.name)} ${chalk.gray(`sha256:${s.sha256?.slice(0, 12)}…`)}`);
|
|
400
|
+
}
|
|
401
|
+
if (options.dryRun) {
|
|
402
|
+
console.log(chalk.gray(`\nDry run — wrote ${indexPath} but did not commit or push.`));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const pushSpinner = ora('Committing and pushing skills-index.json...').start();
|
|
406
|
+
const result = await commitAndPush(repoDir, 'chore: update skills-index.json (agents publish)');
|
|
407
|
+
if (!result.success) {
|
|
408
|
+
pushSpinner.fail(`Push failed: ${result.error}`);
|
|
409
|
+
console.log(chalk.gray('The index was written locally — commit and push it manually to publish.'));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
pushSpinner.succeed('Pushed skills-index.json');
|
|
413
|
+
}
|
|
414
|
+
const rawUrl = `https://raw.githubusercontent.com/${repoSlug}/${options.branch}/skills-index.json`;
|
|
415
|
+
const registryName = options.name || repoSlug.split('/')[1] || 'my-skills';
|
|
416
|
+
console.log(chalk.bold('\nPublished. Share these with anyone who wants your skills:\n'));
|
|
417
|
+
console.log(chalk.gray(' Index URL:'));
|
|
418
|
+
console.log(` ${rawUrl}`);
|
|
419
|
+
console.log(chalk.gray('\n Register + search + install:'));
|
|
420
|
+
console.log(` ${chalk.green(`agents registry add skill ${registryName} ${rawUrl}`)}`);
|
|
421
|
+
console.log(` ${chalk.green(`agents search ${index.skills[0].name} --type skill`)}`);
|
|
422
|
+
console.log(` ${chalk.green(`agents install skill:${index.skills[0].name} --agents claude,codex,gemini`)}`);
|
|
423
|
+
});
|
|
424
|
+
// ==========================================================================
|
|
328
425
|
// INSTALL COMMAND (unified package installation)
|
|
329
426
|
// ==========================================================================
|
|
330
427
|
program
|
|
@@ -448,6 +545,20 @@ When to use:
|
|
|
448
545
|
// --names narrows which specific resources within those kinds.
|
|
449
546
|
console.log(chalk.bold(`\nInstalling from ${resolved.source}`));
|
|
450
547
|
const { localPath } = await cloneRepo(resolved.source);
|
|
548
|
+
// Integrity: a published skill entry carries the sha256 of its
|
|
549
|
+
// SKILL.md. Verify the freshly cloned file against it BEFORE
|
|
550
|
+
// installing anything — a mismatch aborts rather than trusting a
|
|
551
|
+
// tampered artifact.
|
|
552
|
+
if (resolved.type === 'skill' && resolved.skillEntry) {
|
|
553
|
+
const check = verifySkillIntegrity(localPath, resolved.skillEntry);
|
|
554
|
+
if (!check.ok) {
|
|
555
|
+
console.log(chalk.red(`\n${check.error}`));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
if (resolved.skillEntry.sha256) {
|
|
559
|
+
console.log(chalk.gray(` Integrity verified (sha256) for ${resolved.skillEntry.name}`));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
451
562
|
const requestedTypes = new Set(parseCommaSeparatedList(options.types));
|
|
452
563
|
const includeType = (type) => requestedTypes.size === 0 || requestedTypes.has(type);
|
|
453
564
|
const requestedNames = new Set(parseCommaSeparatedList(options.names));
|
|
@@ -10,14 +10,14 @@ import ora from 'ora';
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
|
-
import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon,
|
|
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, } from '../lib/routines.js';
|
|
15
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, } 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';
|
|
19
19
|
import { safeJoin } from '../lib/paths.js';
|
|
20
|
-
import { executeJob, executeJobDetached } from '../lib/runner.js';
|
|
20
|
+
import { executeJob, executeJobDetached, monitorRunningJobs } from '../lib/runner.js';
|
|
21
21
|
import { JobScheduler } from '../lib/scheduler.js';
|
|
22
22
|
import { detectOverdueJobs } from '../lib/overdue.js';
|
|
23
23
|
import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
@@ -175,6 +175,10 @@ export function registerRoutinesCommands(program) {
|
|
|
175
175
|
.description('See all scheduled jobs, when they run next, and their last execution status')
|
|
176
176
|
.option('--json', 'Emit machine-readable JSON instead of the table (used by the menu bar helper)')
|
|
177
177
|
.action((options) => {
|
|
178
|
+
try {
|
|
179
|
+
monitorRunningJobs();
|
|
180
|
+
}
|
|
181
|
+
catch { /* best-effort orphan reap */ }
|
|
178
182
|
const jobs = listAllJobs(process.cwd());
|
|
179
183
|
if (jobs.length === 0) {
|
|
180
184
|
if (options.json) {
|
|
@@ -212,6 +216,8 @@ export function registerRoutinesCommands(program) {
|
|
|
212
216
|
scheduleHuman: fireConditionLabel(job),
|
|
213
217
|
trigger: job.trigger ?? null,
|
|
214
218
|
timezone: job.timezone ?? null,
|
|
219
|
+
device: job.device ?? null,
|
|
220
|
+
runsHere: jobRunsOnThisDevice(job),
|
|
215
221
|
enabled: job.enabled,
|
|
216
222
|
overdue: overdueSet.has(job.name),
|
|
217
223
|
nextRun: nextRun ? nextRun.toISOString() : null,
|
|
@@ -234,12 +240,13 @@ export function registerRoutinesCommands(program) {
|
|
|
234
240
|
const NAME_W = 24;
|
|
235
241
|
const AGENT_W = 10;
|
|
236
242
|
const REPO_W = REPO_DISPLAY_MAX;
|
|
243
|
+
const DEVICE_W = 13;
|
|
237
244
|
const SCHED_W = 22;
|
|
238
245
|
const ENABLED_W = 10;
|
|
239
246
|
const NEXT_W = 22;
|
|
240
|
-
const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
|
|
247
|
+
const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Device'.padEnd(DEVICE_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
|
|
241
248
|
console.log(chalk.gray(header));
|
|
242
|
-
console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
|
|
249
|
+
console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
|
|
243
250
|
for (const job of jobs) {
|
|
244
251
|
const nextRun = scheduler.getNextRun(job.name);
|
|
245
252
|
const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
|
|
@@ -261,6 +268,15 @@ export function registerRoutinesCommands(program) {
|
|
|
261
268
|
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
262
269
|
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
263
270
|
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
271
|
+
// Unpinned jobs run everywhere; a pin that names another machine is
|
|
272
|
+
// grayed — this machine never fires it.
|
|
273
|
+
const deviceWord = job.device || '-';
|
|
274
|
+
const deviceCell = !job.device
|
|
275
|
+
? chalk.gray('-')
|
|
276
|
+
: jobRunsOnThisDevice(job)
|
|
277
|
+
? deviceWord
|
|
278
|
+
: chalk.gray(deviceWord);
|
|
279
|
+
const devicePad = Math.max(0, DEVICE_W - deviceWord.length);
|
|
264
280
|
const statusColor = lastStatus === 'completed' ? chalk.green
|
|
265
281
|
: lastStatus === 'failed' ? chalk.red
|
|
266
282
|
: lastStatus === 'timeout' ? chalk.yellow
|
|
@@ -269,7 +285,7 @@ export function registerRoutinesCommands(program) {
|
|
|
269
285
|
const agentLabelPadded = job.workflow
|
|
270
286
|
? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
|
|
271
287
|
: (job.agent || '').padEnd(10);
|
|
272
|
-
console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
|
|
288
|
+
console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
|
|
273
289
|
}
|
|
274
290
|
if (overdueSet.size > 0) {
|
|
275
291
|
console.log();
|
|
@@ -285,10 +301,11 @@ export function registerRoutinesCommands(program) {
|
|
|
285
301
|
.option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
|
|
286
302
|
.option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
|
|
287
303
|
.option('-p, --prompt <prompt>', 'Task instruction for the agent')
|
|
288
|
-
.option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", '
|
|
304
|
+
.option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier, the default), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'auto')
|
|
289
305
|
.option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
|
|
290
306
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
291
307
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
308
|
+
.option('--device <name>', 'Pin to one machine (routines are fleet-synced): only the device with this name schedules and fires the job')
|
|
292
309
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
293
310
|
.option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
|
|
294
311
|
.option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
|
|
@@ -343,6 +360,7 @@ export function registerRoutinesCommands(program) {
|
|
|
343
360
|
enabled: !options.disabled,
|
|
344
361
|
prompt: options.prompt,
|
|
345
362
|
timezone: options.timezone,
|
|
363
|
+
...(options.device ? { device: options.device } : {}),
|
|
346
364
|
...(runOnce ? { runOnce: true } : {}),
|
|
347
365
|
...(options.endAt ? { endAt: options.endAt } : {}),
|
|
348
366
|
};
|
|
@@ -394,7 +412,7 @@ export function registerRoutinesCommands(program) {
|
|
|
394
412
|
process.exit(1);
|
|
395
413
|
}
|
|
396
414
|
const config = {
|
|
397
|
-
mode: '
|
|
415
|
+
mode: 'auto',
|
|
398
416
|
effort: 'auto',
|
|
399
417
|
timeout: '10m',
|
|
400
418
|
enabled: true,
|
|
@@ -544,6 +562,11 @@ export function registerRoutinesCommands(program) {
|
|
|
544
562
|
console.log(chalk.red(`Job '${name}' not found`));
|
|
545
563
|
process.exit(1);
|
|
546
564
|
}
|
|
565
|
+
if (!jobRunsOnThisDevice(job)) {
|
|
566
|
+
console.log(chalk.red(`Job '${name}' is pinned to device '${job.device}' and never runs here.`));
|
|
567
|
+
console.log(chalk.gray(` Run it there: agents ssh ${job.device} 'agents routines run ${name}'`));
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
547
570
|
const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
|
|
548
571
|
console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
|
|
549
572
|
const spinner = ora('Executing...').start();
|
|
@@ -860,16 +883,33 @@ export function registerRoutinesCommands(program) {
|
|
|
860
883
|
.command('status')
|
|
861
884
|
.description('Show scheduler status, enabled routines, and when each one fires next.')
|
|
862
885
|
.action(() => {
|
|
863
|
-
|
|
864
|
-
|
|
886
|
+
try {
|
|
887
|
+
monitorRunningJobs();
|
|
888
|
+
}
|
|
889
|
+
catch { /* best-effort orphan reap */ }
|
|
890
|
+
const status = getDaemonStatus();
|
|
865
891
|
console.log(chalk.bold('Scheduler\n'));
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
892
|
+
const stateLabel = status.state === 'running'
|
|
893
|
+
? chalk.green('running')
|
|
894
|
+
: status.state === 'wedged'
|
|
895
|
+
? chalk.red('wedged')
|
|
896
|
+
: chalk.gray('stopped');
|
|
897
|
+
console.log(` Status: ${stateLabel}`);
|
|
898
|
+
if (status.pid)
|
|
899
|
+
console.log(` PID: ${status.pid}`);
|
|
900
|
+
if (status.binaryPath)
|
|
901
|
+
console.log(` Binary: ${chalk.gray(status.binaryPath)}`);
|
|
902
|
+
if (status.heartbeat) {
|
|
903
|
+
const ago = Math.round((Date.now() - Date.parse(status.heartbeat.lastTick)) / 1000);
|
|
904
|
+
console.log(` Heartbeat: ${chalk.gray(`${ago} sec ago`)}`);
|
|
905
|
+
}
|
|
869
906
|
const jobs = listAllJobs();
|
|
870
907
|
const enabled = jobs.filter((j) => j.enabled);
|
|
871
908
|
console.log(` Routines: ${enabled.length} enabled / ${jobs.length} total`);
|
|
872
|
-
if (
|
|
909
|
+
if (status.state === 'wedged') {
|
|
910
|
+
console.log(chalk.red('\n The daemon is wedged (heartbeat stale). Restart with: agents routines stop && agents routines start'));
|
|
911
|
+
}
|
|
912
|
+
if (status.running && enabled.length > 0) {
|
|
873
913
|
const scheduler = new JobScheduler(async () => { });
|
|
874
914
|
scheduler.loadAll();
|
|
875
915
|
const scheduled = scheduler.listScheduled();
|
|
@@ -880,7 +920,7 @@ export function registerRoutinesCommands(program) {
|
|
|
880
920
|
}
|
|
881
921
|
scheduler.stopAll();
|
|
882
922
|
}
|
|
883
|
-
else if (!running && jobs.length > 0) {
|
|
923
|
+
else if (!status.running && jobs.length > 0) {
|
|
884
924
|
console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
|
|
885
925
|
}
|
|
886
926
|
});
|
package/dist/commands/ssh.js
CHANGED
|
@@ -20,18 +20,11 @@ import { machineId } from '../lib/session/sync/config.js';
|
|
|
20
20
|
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
21
21
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
|
|
22
22
|
import { localLoginUser, planDeviceReconciliation, runDeviceSync, withDefaultUser } from '../lib/devices/sync.js';
|
|
23
|
-
import { resolveDeviceTarget } from '../lib/devices/resolve-target.js';
|
|
23
|
+
import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-target.js';
|
|
24
24
|
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
25
25
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
26
26
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
27
27
|
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
28
|
-
/** Parse `user@host` or `host` into pieces. */
|
|
29
|
-
function parseTarget(target) {
|
|
30
|
-
const at = target.indexOf('@');
|
|
31
|
-
if (at === -1)
|
|
32
|
-
return { host: target };
|
|
33
|
-
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
34
|
-
}
|
|
35
28
|
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
36
29
|
* command is running on so it stands out from the rest of the tailnet. */
|
|
37
30
|
function deviceSummary(d, isSelf = false) {
|
|
@@ -240,7 +233,7 @@ Typical workflow:
|
|
|
240
233
|
.option('--platform <platform>', 'windows | linux | macos')
|
|
241
234
|
.action(async (name, target, opts) => {
|
|
242
235
|
try {
|
|
243
|
-
const { host, user } =
|
|
236
|
+
const { host, user } = splitUserHost(target);
|
|
244
237
|
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
245
238
|
const d = await upsertDevice(name, {
|
|
246
239
|
platform: opts.platform ?? undefined,
|
package/dist/commands/tmux.js
CHANGED
|
@@ -24,7 +24,7 @@ import chalk from 'chalk';
|
|
|
24
24
|
import { truncate } from '../lib/format.js';
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { setHelpSections } from '../lib/help.js';
|
|
27
|
-
import { assertTmuxAvailable, attachTmux, capturePane, createSession, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, killAll, killSession, listSessions, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
|
|
27
|
+
import { assertTmuxAvailable, attachTmux, capturePane, createSession, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, isTmuxVersionSupported, killAll, killSession, listSessions, MIN_TMUX_VERSION, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
|
|
28
28
|
/** Register the `agents tmux` command tree. */
|
|
29
29
|
export function registerTmuxCommands(program) {
|
|
30
30
|
const tmux = program
|
|
@@ -70,14 +70,22 @@ export function registerTmuxCommands(program) {
|
|
|
70
70
|
checkCmd.action((opts) => {
|
|
71
71
|
const installed = isTmuxInstalled();
|
|
72
72
|
const version = installed ? getTmuxVersion() : null;
|
|
73
|
+
const supported = isTmuxVersionSupported(version);
|
|
73
74
|
if (opts.json) {
|
|
74
|
-
console.log(JSON.stringify({ installed, version, socket: getDefaultSocketPath() }));
|
|
75
|
+
console.log(JSON.stringify({ installed, supported, version, minimumVersion: MIN_TMUX_VERSION, socket: getDefaultSocketPath() }));
|
|
76
|
+
if (installed && !supported)
|
|
77
|
+
process.exitCode = 1;
|
|
75
78
|
return;
|
|
76
79
|
}
|
|
77
|
-
if (installed) {
|
|
80
|
+
if (installed && supported) {
|
|
78
81
|
console.log(chalk.green('tmux:'), version ?? '(version unknown)');
|
|
79
82
|
console.log(chalk.gray(`socket: ${getDefaultSocketPath()}`));
|
|
80
83
|
}
|
|
84
|
+
else if (installed) {
|
|
85
|
+
console.log(chalk.yellow('tmux:'), `${version ?? '(version unknown)'} — unsupported`);
|
|
86
|
+
console.log(chalk.gray(` agents requires tmux ${MIN_TMUX_VERSION} or newer.`));
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
81
89
|
else {
|
|
82
90
|
console.log(chalk.yellow('tmux is not installed.'));
|
|
83
91
|
console.log(chalk.gray(process.platform === 'darwin'
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export declare function readDaemonPid(): number | null;
|
|
|
12
12
|
export declare function writeDaemonPid(pid: number): void;
|
|
13
13
|
/** Remove the daemon PID file. */
|
|
14
14
|
export declare function removeDaemonPid(): void;
|
|
15
|
+
export interface DaemonHeartbeat {
|
|
16
|
+
lastTick: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function writeHeartbeat(pid?: number): void;
|
|
20
|
+
export declare function readHeartbeat(): DaemonHeartbeat | null;
|
|
21
|
+
export declare function removeHeartbeat(): void;
|
|
22
|
+
export declare function isDaemonWedged(): boolean;
|
|
15
23
|
/** Check if the daemon process is alive by sending signal 0 to the stored PID. */
|
|
16
24
|
export declare function isDaemonRunning(): boolean;
|
|
17
25
|
/**
|
|
@@ -128,6 +136,9 @@ export declare function getDaemonLaunch(agentsBin?: string): {
|
|
|
128
136
|
command: string;
|
|
129
137
|
args: string[];
|
|
130
138
|
};
|
|
139
|
+
export declare function validateDaemonBinary(binPath: string): {
|
|
140
|
+
warnings: string[];
|
|
141
|
+
};
|
|
131
142
|
interface StartDetachedOptions {
|
|
132
143
|
/** CLI entry to launch (defaults to the running binary). Injectable for tests. */
|
|
133
144
|
agentsBin?: string;
|
|
@@ -144,10 +155,13 @@ export declare function startDetached(opts?: StartDetachedOptions): {
|
|
|
144
155
|
export declare function stopDaemon(): boolean;
|
|
145
156
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
146
157
|
export declare function getDaemonStatus(): {
|
|
158
|
+
state: 'running' | 'wedged' | 'stopped';
|
|
147
159
|
running: boolean;
|
|
148
160
|
pid: number | null;
|
|
149
161
|
jobCount: number;
|
|
150
162
|
logPath: string;
|
|
163
|
+
binaryPath: string | null;
|
|
164
|
+
heartbeat: DaemonHeartbeat | null;
|
|
151
165
|
};
|
|
152
166
|
/** Read the daemon log, optionally limited to the last N lines. */
|
|
153
167
|
export declare function readDaemonLog(lines?: number): string;
|
package/dist/lib/daemon.js
CHANGED
|
@@ -23,10 +23,13 @@ import { redactSecrets } from './redact.js';
|
|
|
23
23
|
const PID_FILE = 'daemon.pid';
|
|
24
24
|
const LOCK_FILE = 'daemon.lock';
|
|
25
25
|
const LOG_FILE = 'logs.jsonl';
|
|
26
|
+
const HEARTBEAT_FILE = 'heartbeat.json';
|
|
26
27
|
const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
27
28
|
const LOG_ROTATE_COUNT = 3;
|
|
28
29
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
29
30
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
31
|
+
const MONITOR_TICK_MS = 60_000;
|
|
32
|
+
const WEDGE_THRESHOLD_TICKS = 3;
|
|
30
33
|
// A long-lived `claude setup-token` value stored in this secrets bundle/key is
|
|
31
34
|
// baked into the daemon's service-manager environment so headless routine runs
|
|
32
35
|
// authenticate without depending on the short-lived interactive Keychain OAuth
|
|
@@ -118,6 +121,48 @@ export function removeDaemonPid() {
|
|
|
118
121
|
fs.unlinkSync(pidPath);
|
|
119
122
|
}
|
|
120
123
|
}
|
|
124
|
+
function getHeartbeatPath() {
|
|
125
|
+
return path.join(getDaemonDir(), HEARTBEAT_FILE);
|
|
126
|
+
}
|
|
127
|
+
export function writeHeartbeat(pid = process.pid) {
|
|
128
|
+
const hb = { lastTick: new Date().toISOString(), pid };
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(getHeartbeatPath(), JSON.stringify(hb), 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
catch { /* best effort */ }
|
|
133
|
+
}
|
|
134
|
+
export function readHeartbeat() {
|
|
135
|
+
try {
|
|
136
|
+
const raw = fs.readFileSync(getHeartbeatPath(), 'utf-8');
|
|
137
|
+
const hb = JSON.parse(raw);
|
|
138
|
+
if (!hb.lastTick || !hb.pid)
|
|
139
|
+
return null;
|
|
140
|
+
return hb;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function removeHeartbeat() {
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(getHeartbeatPath());
|
|
149
|
+
}
|
|
150
|
+
catch { /* already removed */ }
|
|
151
|
+
}
|
|
152
|
+
export function isDaemonWedged() {
|
|
153
|
+
const pid = readDaemonPid();
|
|
154
|
+
if (!pid)
|
|
155
|
+
return false;
|
|
156
|
+
if (!isAlive(pid))
|
|
157
|
+
return false;
|
|
158
|
+
const hb = readHeartbeat();
|
|
159
|
+
if (!hb)
|
|
160
|
+
return false;
|
|
161
|
+
if (hb.pid !== pid)
|
|
162
|
+
return false;
|
|
163
|
+
const elapsed = Date.now() - Date.parse(hb.lastTick);
|
|
164
|
+
return elapsed > WEDGE_THRESHOLD_TICKS * MONITOR_TICK_MS;
|
|
165
|
+
}
|
|
121
166
|
/** Check if the daemon process is alive by sending signal 0 to the stored PID. */
|
|
122
167
|
export function isDaemonRunning() {
|
|
123
168
|
const pid = readDaemonPid();
|
|
@@ -323,9 +368,11 @@ export async function runDaemon() {
|
|
|
323
368
|
catch (err) {
|
|
324
369
|
log('ERROR', `Browser IPC failed to start: ${err.message}`);
|
|
325
370
|
}
|
|
371
|
+
writeHeartbeat();
|
|
326
372
|
const monitorInterval = setInterval(() => {
|
|
373
|
+
writeHeartbeat();
|
|
327
374
|
monitorRunningJobs();
|
|
328
|
-
},
|
|
375
|
+
}, MONITOR_TICK_MS);
|
|
329
376
|
// Cross-machine session sync: push this machine's transcripts to R2 and pull
|
|
330
377
|
// every other machine's, ~every 90s. Skipped silently when the r2.backups
|
|
331
378
|
// bundle is absent. An overlap guard prevents a slow cycle from stacking.
|
|
@@ -548,6 +595,7 @@ export async function runDaemon() {
|
|
|
548
595
|
clearInterval(launchHealthInterval);
|
|
549
596
|
clearTimeout(launchHealthKickoff);
|
|
550
597
|
removeDaemonPid();
|
|
598
|
+
removeHeartbeat();
|
|
551
599
|
process.exit(0);
|
|
552
600
|
};
|
|
553
601
|
process.on('SIGHUP', handleReload);
|
|
@@ -605,6 +653,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
|
605
653
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
606
654
|
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
607
655
|
const agentsBin = getAgentsBinPath();
|
|
656
|
+
const launch = getDaemonLaunch(agentsBin);
|
|
608
657
|
const logPath = getLogPath();
|
|
609
658
|
const oauthEntry = oauthToken
|
|
610
659
|
? `
|
|
@@ -619,9 +668,7 @@ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken())
|
|
|
619
668
|
<string>${PLIST_NAME}</string>
|
|
620
669
|
<key>ProgramArguments</key>
|
|
621
670
|
<array>
|
|
622
|
-
<string>${
|
|
623
|
-
<string>daemon</string>
|
|
624
|
-
<string>_run</string>
|
|
671
|
+
${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n')}
|
|
625
672
|
</array>
|
|
626
673
|
<key>RunAtLoad</key>
|
|
627
674
|
<true/>
|
|
@@ -639,9 +686,15 @@ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken())
|
|
|
639
686
|
</dict>
|
|
640
687
|
</plist>`;
|
|
641
688
|
}
|
|
689
|
+
/** Quote one systemd ExecStart argument without delegating parsing to a shell. */
|
|
690
|
+
function systemdExecArg(value) {
|
|
691
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
692
|
+
}
|
|
642
693
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
643
694
|
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
644
695
|
const agentsBin = getAgentsBinPath();
|
|
696
|
+
const launch = getDaemonLaunch(agentsBin);
|
|
697
|
+
const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
|
|
645
698
|
const oauthLine = oauthToken
|
|
646
699
|
? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
|
|
647
700
|
: '';
|
|
@@ -651,7 +704,7 @@ After=network.target
|
|
|
651
704
|
|
|
652
705
|
[Service]
|
|
653
706
|
Type=simple
|
|
654
|
-
ExecStart=${
|
|
707
|
+
ExecStart=${execStart}
|
|
655
708
|
Restart=always
|
|
656
709
|
RestartSec=10
|
|
657
710
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
|
|
@@ -842,11 +895,29 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
|
|
|
842
895
|
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
843
896
|
*/
|
|
844
897
|
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
898
|
+
const { warnings } = validateDaemonBinary(agentsBin);
|
|
899
|
+
for (const w of warnings)
|
|
900
|
+
process.stderr.write(`[agents] ${w}\n`);
|
|
845
901
|
if (/\.(c|m)?js$/.test(agentsBin)) {
|
|
846
902
|
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
847
903
|
}
|
|
848
904
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
849
905
|
}
|
|
906
|
+
export function validateDaemonBinary(binPath) {
|
|
907
|
+
const warnings = [];
|
|
908
|
+
if (/\/\$bunfs\/root\//.test(binPath)) {
|
|
909
|
+
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
910
|
+
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
911
|
+
}
|
|
912
|
+
if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
|
|
913
|
+
warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
|
|
914
|
+
`A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
|
|
915
|
+
}
|
|
916
|
+
if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
|
|
917
|
+
warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
|
|
918
|
+
}
|
|
919
|
+
return { warnings };
|
|
920
|
+
}
|
|
850
921
|
export function startDetached(opts = {}) {
|
|
851
922
|
const agentsBin = opts.agentsBin ?? getAgentsBinPath();
|
|
852
923
|
const logPath = opts.logPath ?? getLogPath();
|
|
@@ -948,13 +1019,27 @@ export function stopDaemon() {
|
|
|
948
1019
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
949
1020
|
export function getDaemonStatus() {
|
|
950
1021
|
const running = isDaemonRunning();
|
|
1022
|
+
const wedged = running && isDaemonWedged();
|
|
951
1023
|
const pid = readDaemonPid();
|
|
952
1024
|
let jobCount = 0;
|
|
953
1025
|
try {
|
|
954
1026
|
jobCount = listAllJobs().filter((j) => j.enabled).length;
|
|
955
1027
|
}
|
|
956
1028
|
catch { /* job listing failed */ }
|
|
957
|
-
|
|
1029
|
+
let binaryPath = null;
|
|
1030
|
+
try {
|
|
1031
|
+
binaryPath = getAgentsBinPath();
|
|
1032
|
+
}
|
|
1033
|
+
catch { /* resolution failed */ }
|
|
1034
|
+
return {
|
|
1035
|
+
state: wedged ? 'wedged' : running ? 'running' : 'stopped',
|
|
1036
|
+
running,
|
|
1037
|
+
pid,
|
|
1038
|
+
jobCount,
|
|
1039
|
+
logPath: getLogPath(),
|
|
1040
|
+
binaryPath,
|
|
1041
|
+
heartbeat: readHeartbeat(),
|
|
1042
|
+
};
|
|
958
1043
|
}
|
|
959
1044
|
/** Read the daemon log, optionally limited to the last N lines. */
|
|
960
1045
|
export function readDaemonLog(lines) {
|