@phnx-labs/agents-cli 1.20.54 → 1.20.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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`.
10
+ ## 1.20.55
11
+
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`.
13
+ - **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`.
14
+ - **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`.
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`.
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`.
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`.
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`.
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`.
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)
21
+
5
22
  ## 1.20.54
6
23
 
7
24
  - **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,13 @@ 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; restrict to an allowlist with --devices
647
+ agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
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
645
652
  ```
646
653
 
647
654
  Jobs run sandboxed -- agents only see directories and tools you explicitly allow.
@@ -895,6 +902,8 @@ macOS and Linux. Windows via WSL works but isn't first-class yet.
895
902
 
896
903
  **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
904
 
905
+ Interactive tmux-backed runs require tmux 3.2 or newer.
906
+
898
907
  ### Do I need Node.js?
899
908
 
900
909
  The installer tries Bun first (faster), falls back to npm. Node 22.5+ required at runtime.
package/dist/bin/agents CHANGED
Binary file
@@ -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
- // Spawn the same agents binary the user invoked (process.argv[1]) so version
93
- // pins and wrappers stay consistent. Detach so the resume can take over a TTY
94
- // when interactive; for feed answers we pass the prompt non-interactively.
95
- const bin = process.argv[1] ?? 'agents';
96
- const child = spawn(process.execPath, [bin, ...argv], {
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
  });
@@ -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));
@@ -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 child = spawn(process.argv[0], [
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
- ], { stdio: 'inherit' });
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 {
@@ -10,18 +10,21 @@ 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, readDaemonPid, readDaemonLog, } from '../lib/daemon.js';
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, 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';
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';
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
 
@@ -175,6 +210,10 @@ export function registerRoutinesCommands(program) {
175
210
  .description('See all scheduled jobs, when they run next, and their last execution status')
176
211
  .option('--json', 'Emit machine-readable JSON instead of the table (used by the menu bar helper)')
177
212
  .action((options) => {
213
+ try {
214
+ monitorRunningJobs();
215
+ }
216
+ catch { /* best-effort orphan reap */ }
178
217
  const jobs = listAllJobs(process.cwd());
179
218
  if (jobs.length === 0) {
180
219
  if (options.json) {
@@ -212,6 +251,8 @@ export function registerRoutinesCommands(program) {
212
251
  scheduleHuman: fireConditionLabel(job),
213
252
  trigger: job.trigger ?? null,
214
253
  timezone: job.timezone ?? null,
254
+ devices: job.devices ?? [],
255
+ runsHere: jobRunsOnThisDevice(job),
215
256
  enabled: job.enabled,
216
257
  overdue: overdueSet.has(job.name),
217
258
  nextRun: nextRun ? nextRun.toISOString() : null,
@@ -234,12 +275,13 @@ export function registerRoutinesCommands(program) {
234
275
  const NAME_W = 24;
235
276
  const AGENT_W = 10;
236
277
  const REPO_W = REPO_DISPLAY_MAX;
278
+ const DEVICE_W = 22;
237
279
  const SCHED_W = 22;
238
280
  const ENABLED_W = 10;
239
281
  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`;
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`;
241
283
  console.log(chalk.gray(header));
242
- console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
284
+ console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
243
285
  for (const job of jobs) {
244
286
  const nextRun = scheduler.getNextRun(job.name);
245
287
  const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
@@ -261,6 +303,18 @@ export function registerRoutinesCommands(program) {
261
303
  // chalk adds escape codes; pad the raw word and let chalk wrap it.
262
304
  const enabledWord = job.enabled ? 'yes' : 'no';
263
305
  const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
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')
314
+ : jobRunsOnThisDevice(job)
315
+ ? deviceWord
316
+ : chalk.gray(deviceWord);
317
+ const devicePad = Math.max(0, DEVICE_W - deviceWord.length);
264
318
  const statusColor = lastStatus === 'completed' ? chalk.green
265
319
  : lastStatus === 'failed' ? chalk.red
266
320
  : lastStatus === 'timeout' ? chalk.yellow
@@ -269,7 +323,7 @@ export function registerRoutinesCommands(program) {
269
323
  const agentLabelPadded = job.workflow
270
324
  ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
271
325
  : (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}`);
326
+ 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
327
  }
274
328
  if (overdueSet.size > 0) {
275
329
  console.log();
@@ -285,10 +339,11 @@ export function registerRoutinesCommands(program) {
285
339
  .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
286
340
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
287
341
  .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.", 'plan')
342
+ .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
343
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
290
344
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
291
345
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
346
+ .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
292
347
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
293
348
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
294
349
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
@@ -332,6 +387,11 @@ export function registerRoutinesCommands(program) {
332
387
  console.log(chalk.red('Prompt is required (use --prompt)'));
333
388
  process.exit(1);
334
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
+ }
335
395
  const config = {
336
396
  name: nameOrPath,
337
397
  schedule,
@@ -343,6 +403,7 @@ export function registerRoutinesCommands(program) {
343
403
  enabled: !options.disabled,
344
404
  prompt: options.prompt,
345
405
  timezone: options.timezone,
406
+ ...(devices ? { devices } : {}),
346
407
  ...(runOnce ? { runOnce: true } : {}),
347
408
  ...(options.endAt ? { endAt: options.endAt } : {}),
348
409
  };
@@ -394,7 +455,7 @@ export function registerRoutinesCommands(program) {
394
455
  process.exit(1);
395
456
  }
396
457
  const config = {
397
- mode: 'plan',
458
+ mode: 'auto',
398
459
  effort: 'auto',
399
460
  timeout: '10m',
400
461
  enabled: true,
@@ -544,6 +605,12 @@ export function registerRoutinesCommands(program) {
544
605
  console.log(chalk.red(`Job '${name}' not found`));
545
606
  process.exit(1);
546
607
  }
608
+ const eligibility = checkJobDeviceEligibility(job);
609
+ if (eligibility) {
610
+ console.log(chalk.red(eligibility.message));
611
+ console.log(chalk.gray(` ${eligibility.suggestion}`));
612
+ process.exit(1);
613
+ }
547
614
  const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
548
615
  console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
549
616
  const spinner = ora('Executing...').start();
@@ -829,6 +896,87 @@ export function registerRoutinesCommands(program) {
829
896
  process.exit(1);
830
897
  }
831
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
+ });
832
980
  // Scheduler lifecycle — usually auto-managed by `routines add`, exposed here for manual control.
833
981
  routinesCmd
834
982
  .command('start')
@@ -860,16 +1008,33 @@ export function registerRoutinesCommands(program) {
860
1008
  .command('status')
861
1009
  .description('Show scheduler status, enabled routines, and when each one fires next.')
862
1010
  .action(() => {
863
- const running = isDaemonRunning();
864
- const pid = readDaemonPid();
1011
+ try {
1012
+ monitorRunningJobs();
1013
+ }
1014
+ catch { /* best-effort orphan reap */ }
1015
+ const status = getDaemonStatus();
865
1016
  console.log(chalk.bold('Scheduler\n'));
866
- console.log(` Status: ${running ? chalk.green('running') : chalk.gray('stopped')}`);
867
- if (pid)
868
- console.log(` PID: ${pid}`);
1017
+ const stateLabel = status.state === 'running'
1018
+ ? chalk.green('running')
1019
+ : status.state === 'wedged'
1020
+ ? chalk.red('wedged')
1021
+ : chalk.gray('stopped');
1022
+ console.log(` Status: ${stateLabel}`);
1023
+ if (status.pid)
1024
+ console.log(` PID: ${status.pid}`);
1025
+ if (status.binaryPath)
1026
+ console.log(` Binary: ${chalk.gray(status.binaryPath)}`);
1027
+ if (status.heartbeat) {
1028
+ const ago = Math.round((Date.now() - Date.parse(status.heartbeat.lastTick)) / 1000);
1029
+ console.log(` Heartbeat: ${chalk.gray(`${ago} sec ago`)}`);
1030
+ }
869
1031
  const jobs = listAllJobs();
870
1032
  const enabled = jobs.filter((j) => j.enabled);
871
1033
  console.log(` Routines: ${enabled.length} enabled / ${jobs.length} total`);
872
- if (running && enabled.length > 0) {
1034
+ if (status.state === 'wedged') {
1035
+ console.log(chalk.red('\n The daemon is wedged (heartbeat stale). Restart with: agents routines stop && agents routines start'));
1036
+ }
1037
+ if (status.running && enabled.length > 0) {
873
1038
  const scheduler = new JobScheduler(async () => { });
874
1039
  scheduler.loadAll();
875
1040
  const scheduled = scheduler.listScheduled();
@@ -880,7 +1045,7 @@ export function registerRoutinesCommands(program) {
880
1045
  }
881
1046
  scheduler.stopAll();
882
1047
  }
883
- else if (!running && jobs.length > 0) {
1048
+ else if (!status.running && jobs.length > 0) {
884
1049
  console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
885
1050
  }
886
1051
  });
@@ -910,4 +1075,9 @@ export function registerRoutinesCommands(program) {
910
1075
  console.log(chalk.gray('No scheduler logs'));
911
1076
  }
912
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
+ }
913
1083
  }
@@ -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 } = parseTarget(target);
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,