@phnx-labs/agents-cli 1.20.55 → 1.20.57

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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.57
6
+
7
+ - **`agents teams resume` / `agents teams message` — resume a stopped teammate with a follow-up message.** A teammate that ended its turn with more to do (PR open awaiting review, headless turn cap, a redirect after the fact) could not be reached: `agents message` resolves only *live* sessions, so a completed/stopped/failed teammate had no path back short of finishing the work by hand or spawning a fresh, context-less teammate. `teams resume <team> <teammate> <message>` re-enters the teammate's **own** session with the message as the next user turn, re-launching through the same backend (local process or remote host) in its original worktree and flipping it back to `running` so `teams status` tracks it live. `teams message` is the same command with automatic routing by reconciled status: a **running** teammate is steered via its mailbox (delivered at its next tool call, no re-launch); a **stopped** one is resumed; a **pending** one is refused with a pointer to `teams start`. Works for every harness — the resume delegates to `agents run --resume`, inheriting native resume for Claude/Codex and the universal `/continue` replay for the rest (OpenCode, Grok, Kimi, …); the resume target is the teammate's captured underlying session id (`remoteSessionId ?? agentId`), and a non-Claude teammate that died before emitting a session id is refused with a clear error rather than resumed into a fresh run. This also makes good on `teams stop`'s long-standing "can be restarted later" promise, which no code implemented. Source: `apps/cli/src/commands/teams.ts` (`message`/`resume` subcommands, `decideTeamMessageRoute`), `apps/cli/src/lib/teams/agents.ts` (`AgentManager.resumeTeammate`, resume-aware `buildRunArgv`/`buildCommand`/`launchProcess`/`launchRemoteProcess`).
8
+ - **The always-on daemon now hosts the secrets broker (socket-first) — one supervised backbone instead of a separate service (#416, step 1).** `runDaemon()` binds the broker via the new `startHostedBroker()` before the scheduler and the heavy browser/session-sync services, so `agents secrets` resolves within ms of daemon start. It serves the same socket + wire protocol as the standalone broker (no `PROTOCOL_VERSION` bump — `agentGetSync`/`agentPing`/`agentAutoLoadSync` are unchanged), but is daemon-safe: no pid-guard, no `process.exit`/signal handlers/self-heal-exit (which would take the daemon down), TTL-eviction only. `ensureAgentRunning()` gains a Path 0 that prefers the daemon and falls back to the standalone `com.phnx-labs.agents-secrets-agent` launchd service, and the daemon only hosts when no broker is already reachable, so a live standalone broker is never orphaned. Retiring the standalone service (a gated `launchctl bootout` migration) and child-spawning the heavy services are the follow-on (#417). Source: `apps/cli/src/lib/secrets/agent.ts` (`startHostedBroker`, `ensureAgentRunning` Path 0, `agentPing` exported), `apps/cli/src/lib/daemon.ts` (`runDaemon` broker host + shutdown).
9
+ - **Clarified `agents secrets list` POLICY column labels.** The column previously mixed policy names, runtime state, and implementation jargon (`daily · 7d left`, `always ask`, `never · NO ACL`). It now uses a consistent `policy · state` form: `daily`, `daily · held 7d`, `always · prompt`, and `never · no prompt`. Source: `apps/cli/src/commands/secrets.ts` (`renderPolicyCol`).
10
+
11
+ ## 1.20.56
12
+
13
+ - **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`.
14
+ - **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
15
  ## 1.20.55
6
16
 
7
17
  - **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 +20,7 @@
10
20
  - **`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
21
  - **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
22
  - **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`.
23
+ - **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
24
  - **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
25
  - **`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
26
 
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; pin one to a single machine with --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
- --device yosemite-s0 --prompt "Drain the work queue"
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
@@ -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
  });
@@ -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 {
@@ -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
- device: job.device ?? null,
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 = 13;
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)} ${'Device'.padEnd(DEVICE_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`;
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
- // 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('-')
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('--device <name>', 'Pin to one machine (routines are fleet-synced): only the device with this name schedules and fires the job')
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
- ...(options.device ? { device: options.device } : {}),
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
- 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}'`));
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
  }
@@ -66,8 +66,9 @@ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secret
66
66
  * parse, so multi-line values are rejected rather than silently corrupted.
67
67
  */
68
68
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
69
- /** The POLICY column for `secrets list`: the prompt policy, plus a "Nh left"
70
- * hint when a `daily` bundle is currently held by the secrets-agent. `held`
69
+ /** The POLICY column for `secrets list`: the prompt policy, plus a concise
70
+ * state hint. `daily` shows `held Nh` when the secrets-agent is currently
71
+ * caching the bundle; `always` and `never` show whether they prompt. `held`
71
72
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
72
73
  export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
73
74
  /** Register the `agents secrets` command tree. */
@@ -387,17 +387,18 @@ function compactRemaining(expiresAt) {
387
387
  return `${hours}h`;
388
388
  return `${Math.round(hours / 24)}d`;
389
389
  }
390
- /** The POLICY column for `secrets list`: the prompt policy, plus a "Nh left"
391
- * hint when a `daily` bundle is currently held by the secrets-agent. `held`
390
+ /** The POLICY column for `secrets list`: the prompt policy, plus a concise
391
+ * state hint. `daily` shows `held Nh` when the secrets-agent is currently
392
+ * caching the bundle; `always` and `never` show whether they prompt. `held`
392
393
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
393
394
  export function renderPolicyCol(b, held) {
394
395
  // `never` is loud on purpose — it's the only tier with no user-presence gate.
395
396
  if (bundlePolicy(b) === 'never')
396
- return chalk.red.bold('never · NO ACL');
397
+ return chalk.red.bold('never · no prompt');
397
398
  if (bundlePolicy(b) === 'always')
398
- return chalk.yellow('always ask');
399
+ return chalk.yellow('always · prompt');
399
400
  const exp = held?.get(b.name);
400
- return exp ? chalk.green(`daily · ${compactRemaining(exp)} left`) : chalk.gray('daily');
401
+ return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
401
402
  }
402
403
  /** Below this width the fixed date columns no longer fit; `list` uses cards. */
403
404
  const SECRETS_WIDE = 96;
@@ -657,7 +658,7 @@ export function registerSecretsCommands(program) {
657
658
  return;
658
659
  }
659
660
  // Cross-reference the secrets-agent so `daily` bundles that are currently
660
- // held can show "· Nh left". Soft-fails to no hint if the broker is down.
661
+ // held can show "· held Nh". Soft-fails to no hint if the broker is down.
661
662
  const held = new Map();
662
663
  if (process.platform === 'darwin') {
663
664
  try {
@@ -6,7 +6,26 @@
6
6
  * dependencies between teammates, and clean up when work is done.
7
7
  */
8
8
  import type { Command } from 'commander';
9
- import { AgentManager } from '../lib/teams/agents.js';
9
+ import { AgentManager, AgentStatus } from '../lib/teams/agents.js';
10
+ /** Where `teams message`/`teams resume` routes a follow-up, by teammate status. */
11
+ export type TeamMessageRoute = {
12
+ kind: 'steer';
13
+ } | {
14
+ kind: 'resume';
15
+ } | {
16
+ kind: 'need-message';
17
+ } | {
18
+ kind: 'not-started';
19
+ };
20
+ /**
21
+ * Decide how a follow-up to a teammate is delivered from its reconciled status.
22
+ * Pure — the source of truth for the routing table, unit-tested without I/O.
23
+ * - pending -> not-started (tell them to `teams start`)
24
+ * - running + message -> steer (mailbox)
25
+ * - stopped/etc + message -> resume (re-enter session)
26
+ * - any actionable + no message -> need-message
27
+ */
28
+ export declare function decideTeamMessageRoute(status: AgentStatus, hasMessage: boolean): TeamMessageRoute;
10
29
  /**
11
30
  * Register the generic cloud dispatcher — staged cloud teammates get
12
31
  * dispatched when their --after deps resolve, using repo/branch stored on
@@ -3,7 +3,8 @@ import { die, relTime, truncate, isJsonMode, padRight } from '../lib/format.js';
3
3
  import * as fs from 'fs/promises';
4
4
  import { addHostOption } from '../lib/hosts/option.js';
5
5
  import * as path from 'path';
6
- import { AgentManager, checkCliSignedIn, collectTeamsDoctorData, getAgentsDir, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
6
+ import { AgentManager, AgentStatus, checkCliSignedIn, collectTeamsDoctorData, getAgentsDir, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
7
+ import { mailboxDir, enqueue } from '../lib/mailbox.js';
7
8
  import { resolveProvider } from '../lib/cloud/registry.js';
8
9
  import { emit } from '../lib/events.js';
9
10
  import { runSupervisor } from '../lib/teams/supervisor.js';
@@ -126,6 +127,23 @@ function parseTeammate(spec) {
126
127
  function shortId(id) {
127
128
  return id.slice(0, 8);
128
129
  }
130
+ /**
131
+ * Decide how a follow-up to a teammate is delivered from its reconciled status.
132
+ * Pure — the source of truth for the routing table, unit-tested without I/O.
133
+ * - pending -> not-started (tell them to `teams start`)
134
+ * - running + message -> steer (mailbox)
135
+ * - stopped/etc + message -> resume (re-enter session)
136
+ * - any actionable + no message -> need-message
137
+ */
138
+ export function decideTeamMessageRoute(status, hasMessage) {
139
+ if (status === AgentStatus.PENDING)
140
+ return { kind: 'not-started' };
141
+ if (!hasMessage)
142
+ return { kind: 'need-message' };
143
+ if (status === AgentStatus.RUNNING)
144
+ return { kind: 'steer' };
145
+ return { kind: 'resume' };
146
+ }
129
147
  /**
130
148
  * Preamble injected into every factory worker's prompt. Tells the worker
131
149
  * which team + teammate name + task-type it is, and how to file new tasks.
@@ -816,6 +834,12 @@ export function registerTeamsCommands(program) {
816
834
  # Delta-poll status without rereading everything
817
835
  agents teams status pricing-page --since 2026-04-24T09:00:00-07:00
818
836
 
837
+ # Nudge a teammate that stopped with more to do — resumes its own session
838
+ agents teams resume pricing-page backend "Review's in — rebase-merge the PR, then release"
839
+
840
+ # Steer a still-running teammate mid-flight (delivered at its next tool call)
841
+ agents teams message pricing-page qa "Skip the flaky screenshot test for now"
842
+
819
843
  # Wind everyone down when shipped
820
844
  agents teams disband pricing-page
821
845
  `,
@@ -1646,7 +1670,7 @@ export function registerTeamsCommands(program) {
1646
1670
  });
1647
1671
  // stop
1648
1672
  addHostOption(teams.command('stop [team] [teammate]'))
1649
- .description('Stop a running teammate. Can be restarted later. Cleans up worktree if no uncommitted changes.')
1673
+ .description('Stop a running teammate. Resume it later with `agents teams resume`. Cleans up worktree if no uncommitted changes.')
1650
1674
  .option('--json', 'Output machine-readable JSON')
1651
1675
  .action(async (team, ref, opts) => {
1652
1676
  const mgr = mkManager();
@@ -1733,6 +1757,85 @@ export function registerTeamsCommands(program) {
1733
1757
  console.log(chalk.yellow(`Worktree '${agent.worktreeName}' has uncommitted changes. Keeping it at: ${agent.worktreePath}`));
1734
1758
  }
1735
1759
  });
1760
+ // message / resume — send a follow-up message to a teammate. Routes by the
1761
+ // teammate's reconciled status: a RUNNING teammate is STEERED via its mailbox
1762
+ // (delivered at its next tool call); a STOPPED one (completed/failed/stopped)
1763
+ // is RESUMED — re-entering its own session with the message as the next user
1764
+ // turn, re-attaching it to the team as live.
1765
+ async function teamMessageAction(team, ref, message, opts) {
1766
+ const mgr = mkManager();
1767
+ const lookup = await mgr.resolveAgentIdInTask(team, ref);
1768
+ if (lookup.kind === 'none')
1769
+ die(`No teammate matching '${ref}' in team ${team}`, 2);
1770
+ if (lookup.kind === 'ambiguous') {
1771
+ const shorts = lookup.matches.map(shortId).join(', ');
1772
+ die(`'${ref}' matches multiple teammates: ${shorts}. Use more characters or a name.`, 2);
1773
+ }
1774
+ const agentId = lookup.agentId;
1775
+ // mgr.get reconciles the teammate's status (PID + start-time guard / remote
1776
+ // .exit sentinel / exit-code reap) before we branch — so running-vs-stopped
1777
+ // is a fact, not a guess.
1778
+ const agent = await mgr.get(agentId);
1779
+ if (!agent)
1780
+ die(`Teammate ${shortId(agentId)} vanished from team ${team}.`);
1781
+ const display = agent.name || shortId(agentId);
1782
+ const status = agent.status;
1783
+ const hasMessage = message != null && message.trim().length > 0;
1784
+ const route = decideTeamMessageRoute(status, hasMessage);
1785
+ switch (route.kind) {
1786
+ case 'not-started':
1787
+ die(`Teammate '${display}' hasn't started yet (waiting on --after deps). Run \`agents teams start ${team}\` to launch it.`);
1788
+ return;
1789
+ case 'need-message':
1790
+ if (status === AgentStatus.RUNNING) {
1791
+ die(`Teammate '${display}' is running — pass a message to steer it.`);
1792
+ }
1793
+ die(`Teammate '${display}' is ${status} — pass a message to resume it: \`agents teams resume ${team} ${display} "<message>"\`.`);
1794
+ return;
1795
+ case 'steer': {
1796
+ // Running -> steer via mailbox; never re-launch (that forks a 2nd session).
1797
+ enqueue(mailboxDir(agentId), { to: agentId, text: message, from: opts.from });
1798
+ if (isJsonMode(opts)) {
1799
+ console.log(JSON.stringify({ team, agent_id: agentId, name: agent.name ?? null, action: 'steer', status }, null, 2));
1800
+ return;
1801
+ }
1802
+ console.log(chalk.green(`Steering ${chalk.cyan(display)} (running) — `) +
1803
+ chalk.dim('message queued; it will see it at its next tool call.'));
1804
+ return;
1805
+ }
1806
+ case 'resume': {
1807
+ // Stopped / completed / failed -> resume its own session with the message.
1808
+ try {
1809
+ await mgr.resumeTeammate(agentId, message);
1810
+ }
1811
+ catch (err) {
1812
+ die(err.message);
1813
+ }
1814
+ if (isJsonMode(opts)) {
1815
+ console.log(JSON.stringify({ team, agent_id: agentId, name: agent.name ?? null, action: 'resume', prior_status: status }, null, 2));
1816
+ return;
1817
+ }
1818
+ console.log(chalk.green(`Resuming ${chalk.cyan(display)} `) +
1819
+ chalk.dim(`(was ${status}) in team ${team} — re-entering its session with your message.`));
1820
+ console.log(chalk.dim(`Track it with \`agents teams status ${team}\`.`));
1821
+ return;
1822
+ }
1823
+ }
1824
+ }
1825
+ addHostOption(teams.command('message <team> <teammate> <message>'))
1826
+ .description('Send a follow-up message to a teammate. A running teammate is steered via its mailbox; a stopped one is resumed — re-entering its own session with the message.')
1827
+ .option('--from <who>', 'Label recorded as the sender of this message')
1828
+ .option('--json', 'Output machine-readable JSON')
1829
+ .action(async (team, ref, message, opts) => {
1830
+ await teamMessageAction(team, ref, message, opts);
1831
+ });
1832
+ addHostOption(teams.command('resume <team> <teammate> [message]'))
1833
+ .description("Resume a stopped teammate (completed/failed/stopped) by re-entering its own session with a message as the next user turn. If the teammate is still running, the message is steered via its mailbox instead.")
1834
+ .option('--from <who>', 'Label recorded as the sender of this message')
1835
+ .option('--json', 'Output machine-readable JSON')
1836
+ .action(async (team, ref, message, opts) => {
1837
+ await teamMessageAction(team, ref, message, opts);
1838
+ });
1736
1839
  // remove
1737
1840
  teams
1738
1841
  .command('remove [team] [teammate]')
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 = 'v11';
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) {
@@ -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
  };