@phnx-labs/agents-cli 1.20.64 → 1.20.65

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.
Files changed (155) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +37 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +94 -49
  10. package/dist/commands/feed.js +25 -11
  11. package/dist/commands/hosts.js +44 -6
  12. package/dist/commands/mcp.js +55 -5
  13. package/dist/commands/monitors.d.ts +12 -0
  14. package/dist/commands/monitors.js +740 -0
  15. package/dist/commands/output.js +2 -2
  16. package/dist/commands/routines.js +23 -2
  17. package/dist/commands/secrets.d.ts +16 -0
  18. package/dist/commands/secrets.js +215 -64
  19. package/dist/commands/serve.js +31 -0
  20. package/dist/commands/sessions-export.js +8 -3
  21. package/dist/commands/sessions.d.ts +16 -0
  22. package/dist/commands/sessions.js +36 -6
  23. package/dist/commands/ssh.js +45 -2
  24. package/dist/commands/versions.js +7 -3
  25. package/dist/commands/view.d.ts +26 -0
  26. package/dist/commands/view.js +32 -9
  27. package/dist/commands/webhook.js +10 -2
  28. package/dist/index.js +34 -14
  29. package/dist/lib/agents.d.ts +18 -0
  30. package/dist/lib/agents.js +53 -3
  31. package/dist/lib/auto-dispatch-provider.js +7 -2
  32. package/dist/lib/auto-dispatch.d.ts +3 -0
  33. package/dist/lib/auto-dispatch.js +3 -0
  34. package/dist/lib/browser/chrome.js +2 -2
  35. package/dist/lib/cloud/antigravity.js +2 -2
  36. package/dist/lib/cloud/host.d.ts +59 -0
  37. package/dist/lib/cloud/host.js +224 -0
  38. package/dist/lib/cloud/registry.js +4 -0
  39. package/dist/lib/cloud/types.d.ts +6 -4
  40. package/dist/lib/computer-rpc.js +3 -1
  41. package/dist/lib/crabbox/cli.js +5 -1
  42. package/dist/lib/crabbox/runtimes.js +11 -2
  43. package/dist/lib/daemon.d.ts +20 -4
  44. package/dist/lib/daemon.js +62 -19
  45. package/dist/lib/devices/fleet.d.ts +3 -2
  46. package/dist/lib/devices/fleet.js +9 -0
  47. package/dist/lib/devices/registry.d.ts +15 -0
  48. package/dist/lib/devices/registry.js +9 -0
  49. package/dist/lib/exec.d.ts +19 -2
  50. package/dist/lib/exec.js +41 -13
  51. package/dist/lib/fleet/apply.d.ts +63 -0
  52. package/dist/lib/fleet/apply.js +214 -0
  53. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  54. package/dist/lib/fleet/auth-sync.js +142 -0
  55. package/dist/lib/fleet/manifest.d.ts +29 -0
  56. package/dist/lib/fleet/manifest.js +127 -0
  57. package/dist/lib/fleet/types.d.ts +129 -0
  58. package/dist/lib/fleet/types.js +13 -0
  59. package/dist/lib/git.d.ts +27 -0
  60. package/dist/lib/git.js +34 -2
  61. package/dist/lib/hosts/dispatch.d.ts +29 -8
  62. package/dist/lib/hosts/dispatch.js +46 -18
  63. package/dist/lib/hosts/passthrough.js +2 -0
  64. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  65. package/dist/lib/hosts/providers/devices.js +98 -0
  66. package/dist/lib/hosts/registry.d.ts +10 -16
  67. package/dist/lib/hosts/registry.js +17 -50
  68. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  69. package/dist/lib/hosts/remote-cmd.js +71 -0
  70. package/dist/lib/hosts/run-target.d.ts +84 -0
  71. package/dist/lib/hosts/run-target.js +99 -0
  72. package/dist/lib/hosts/types.d.ts +23 -5
  73. package/dist/lib/hosts/types.js +22 -4
  74. package/dist/lib/linear-autoclose.d.ts +30 -0
  75. package/dist/lib/linear-autoclose.js +22 -0
  76. package/dist/lib/mcp.d.ts +27 -1
  77. package/dist/lib/mcp.js +126 -12
  78. package/dist/lib/monitors/config.d.ts +161 -0
  79. package/dist/lib/monitors/config.js +372 -0
  80. package/dist/lib/monitors/dispatch.d.ts +28 -0
  81. package/dist/lib/monitors/dispatch.js +91 -0
  82. package/dist/lib/monitors/engine.d.ts +61 -0
  83. package/dist/lib/monitors/engine.js +201 -0
  84. package/dist/lib/monitors/sources/command.d.ts +11 -0
  85. package/dist/lib/monitors/sources/command.js +31 -0
  86. package/dist/lib/monitors/sources/device.d.ts +13 -0
  87. package/dist/lib/monitors/sources/device.js +35 -0
  88. package/dist/lib/monitors/sources/file.d.ts +14 -0
  89. package/dist/lib/monitors/sources/file.js +57 -0
  90. package/dist/lib/monitors/sources/http.d.ts +10 -0
  91. package/dist/lib/monitors/sources/http.js +34 -0
  92. package/dist/lib/monitors/sources/index.d.ts +14 -0
  93. package/dist/lib/monitors/sources/index.js +31 -0
  94. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  95. package/dist/lib/monitors/sources/poll.js +9 -0
  96. package/dist/lib/monitors/sources/types.d.ts +18 -0
  97. package/dist/lib/monitors/sources/types.js +9 -0
  98. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  99. package/dist/lib/monitors/sources/webhook.js +47 -0
  100. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  101. package/dist/lib/monitors/sources/ws.js +45 -0
  102. package/dist/lib/monitors/state.d.ts +69 -0
  103. package/dist/lib/monitors/state.js +144 -0
  104. package/dist/lib/platform/exec.d.ts +16 -0
  105. package/dist/lib/platform/exec.js +17 -0
  106. package/dist/lib/plugins.js +101 -2
  107. package/dist/lib/redact.d.ts +14 -1
  108. package/dist/lib/redact.js +47 -1
  109. package/dist/lib/remote-agents-json.js +7 -1
  110. package/dist/lib/rotate.d.ts +6 -3
  111. package/dist/lib/rotate.js +0 -1
  112. package/dist/lib/routines.d.ts +16 -0
  113. package/dist/lib/routines.js +19 -0
  114. package/dist/lib/runner.d.ts +1 -0
  115. package/dist/lib/runner.js +102 -9
  116. package/dist/lib/secrets/agent.d.ts +48 -10
  117. package/dist/lib/secrets/agent.js +123 -15
  118. package/dist/lib/secrets/bundles.d.ts +26 -0
  119. package/dist/lib/secrets/bundles.js +59 -8
  120. package/dist/lib/secrets/mcp.js +4 -2
  121. package/dist/lib/secrets/remote.d.ts +17 -0
  122. package/dist/lib/secrets/remote.js +40 -0
  123. package/dist/lib/self-update.d.ts +20 -0
  124. package/dist/lib/self-update.js +54 -1
  125. package/dist/lib/serve/control.d.ts +95 -0
  126. package/dist/lib/serve/control.js +260 -0
  127. package/dist/lib/serve/server.d.ts +35 -1
  128. package/dist/lib/serve/server.js +106 -76
  129. package/dist/lib/serve/stream.d.ts +43 -0
  130. package/dist/lib/serve/stream.js +116 -0
  131. package/dist/lib/serve/token.d.ts +35 -0
  132. package/dist/lib/serve/token.js +85 -0
  133. package/dist/lib/session/bundle.d.ts +14 -0
  134. package/dist/lib/session/bundle.js +12 -1
  135. package/dist/lib/session/remote-list.js +5 -1
  136. package/dist/lib/session/state.d.ts +7 -25
  137. package/dist/lib/session/state.js +16 -6
  138. package/dist/lib/session/sync/config.js +8 -2
  139. package/dist/lib/session/types.d.ts +30 -0
  140. package/dist/lib/ssh-tunnel.d.ts +19 -1
  141. package/dist/lib/ssh-tunnel.js +86 -7
  142. package/dist/lib/startup/command-registry.d.ts +2 -0
  143. package/dist/lib/startup/command-registry.js +4 -0
  144. package/dist/lib/state.d.ts +5 -0
  145. package/dist/lib/state.js +12 -0
  146. package/dist/lib/tmux/session.d.ts +7 -0
  147. package/dist/lib/tmux/session.js +3 -1
  148. package/dist/lib/triggers/webhook.d.ts +18 -0
  149. package/dist/lib/triggers/webhook.js +105 -0
  150. package/dist/lib/types.d.ts +26 -1
  151. package/dist/lib/usage.js +7 -5
  152. package/dist/lib/versions.js +14 -11
  153. package/dist/lib/workflows.d.ts +20 -0
  154. package/dist/lib/workflows.js +24 -0
  155. package/package.json +2 -1
@@ -8,6 +8,7 @@ import { insertTask, updateTaskStatus, getTaskById, listTasks as listStoredTasks
8
8
  import { renderStream } from '../lib/cloud/stream.js';
9
9
  import { MissingTargetError, MAX_IMAGES_PER_DISPATCH } from '../lib/cloud/types.js';
10
10
  import { normalizeTriggerEvent, validateTrigger, writeJob, jobExists, GITHUB_TRIGGER_EVENTS } from '../lib/routines.js';
11
+ import { machineId } from '../lib/machine-id.js';
11
12
  import { emit } from '../lib/events.js';
12
13
  /** Map a supported image file extension to its wire mimeType. Rejects anything else. */
13
14
  function imageMimeFromPath(file) {
@@ -145,7 +146,7 @@ Examples:
145
146
  cloud
146
147
  .command('run [prompt]')
147
148
  .description('Dispatch a task to a cloud agent.')
148
- .option('--provider <id>', 'Cloud backend: rush, codex, factory, antigravity (overrides agent auto-routing)')
149
+ .option('--provider <id>', 'Cloud backend: rush, codex, factory, antigravity, host (overrides agent auto-routing)')
149
150
  .option('--agent <name>', 'Agent to run: claude, codex, droid, antigravity (auto-routes to its native cloud)')
150
151
  .option('--repo <owner/repo>', 'GitHub repository. Repeatable for multi-repo dispatch (Rush Cloud only).', (value, previous) => {
151
152
  const acc = Array.isArray(previous) ? previous : [];
@@ -160,6 +161,9 @@ Examples:
160
161
  .option('--model <model>', 'Model override')
161
162
  .option('--env <id>', 'Codex Cloud environment ID')
162
163
  .option('--computer <name>', 'Factory/Droid computer target')
164
+ .option('--host <name>', 'One of your machines as the target (a registered host, device, capability tag, or user@host). Implies --provider host.')
165
+ .option('--remote-cwd <dir>', 'Working directory on the host (--provider host only)')
166
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match')
163
167
  .option('--autonomy <level>', 'Factory/Droid autonomy: low, medium, high (default high)')
164
168
  .option('--mode <mode>', 'Execution mode (e.g., plan, edit, full)')
165
169
  .option('--image <path>', `Attach an image (.png/.jpg/.webp) for vision dispatch. Repeatable, up to ${MAX_IMAGES_PER_DISPATCH} (Rush Cloud only).`, (value, previous) => {
@@ -189,6 +193,10 @@ Examples:
189
193
  # Codex Cloud
190
194
  agents cloud run "add auth tests" --provider codex --env env_abc123
191
195
 
196
+ # One of your own machines (agents hosts / agents devices), over SSH
197
+ agents cloud run "run the nightly benchmark" --host gpu-box --agent claude
198
+ agents cloud run "rebuild the index" --host gpu --any --remote-cwd ~/proj
199
+
192
200
  # Default provider (set in ~/.agents/agents.yaml)
193
201
  agents cloud run "refactor auth module" --repo user/repo
194
202
  `)
@@ -208,9 +216,16 @@ Examples:
208
216
  process.stderr.write(chalk.dim(`Reading prompt from ${filePath} (${sizeKB} KB)\n`));
209
217
  }
210
218
  }
219
+ // --host names one of YOUR machines as the target — that only means
220
+ // something to the host provider, so it implies --provider host rather
221
+ // than silently riding along to a cloud backend that would ignore it.
222
+ if (options.host && options.provider && options.provider !== 'host') {
223
+ die(`--host targets your own machines (--provider host), not ${options.provider}. Drop --host, or use --provider host.`);
224
+ }
225
+ const explicitProvider = options.provider ?? (options.host ? 'host' : undefined);
211
226
  // Agent-aware: with no --provider, the agent routes to its native cloud
212
227
  // (claude→rush, codex→codex, droid→factory, antigravity→antigravity).
213
- const provider = resolveProvider(options.provider, options.agent);
228
+ const provider = resolveProvider(explicitProvider, options.agent);
214
229
  // --repo is repeatable: commander gives us an array via our collector.
215
230
  // A single --repo value arrives as a one-element array; keep the legacy
216
231
  // singular `repo` field in sync so providers that only know that field
@@ -234,6 +249,12 @@ Examples:
234
249
  dispatchOptions.providerOptions.env = options.env;
235
250
  if (options.computer)
236
251
  dispatchOptions.providerOptions.computer = options.computer;
252
+ if (options.host)
253
+ dispatchOptions.providerOptions.host = options.host;
254
+ if (options.remoteCwd)
255
+ dispatchOptions.providerOptions.remoteCwd = options.remoteCwd;
256
+ if (options.any)
257
+ dispatchOptions.providerOptions.any = true;
237
258
  if (options.autonomy)
238
259
  dispatchOptions.providerOptions.autonomy = options.autonomy;
239
260
  if (options.mode)
@@ -279,6 +300,15 @@ Examples:
279
300
  };
280
301
  if (repoValues[0])
281
302
  routine.repo = repoValues[0];
303
+ // --host with --on: the webhook-fired run places on that machine (the
304
+ // routine carries the placement, and firing pins to THIS device so a
305
+ // fleet of receivers can't each dispatch a duplicate).
306
+ if (options.host) {
307
+ routine.host = options.host;
308
+ if (options.remoteCwd)
309
+ routine.remoteCwd = options.remoteCwd;
310
+ routine.devices = [machineId()];
311
+ }
282
312
  writeJob(routine);
283
313
  if (json) {
284
314
  console.log(JSON.stringify({ ok: true, registered: routineName, trigger }, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { addHostOption } from '../lib/hosts/option.js';
2
2
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
3
- import { loadDevices } from '../lib/devices/registry.js';
3
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
4
4
  import { resolveHost } from '../lib/hosts/registry.js';
5
5
  import { sshExec } from '../lib/ssh-exec.js';
6
6
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -249,6 +249,9 @@ async function resolveFleetTargets(opts) {
249
249
  const localName = machineId();
250
250
  return Object.values(registry)
251
251
  .filter((d) => d.name.toLowerCase() !== localName)
252
+ // Control devices (a cockpit) never run agents — skip them in the fleet
253
+ // fan-out (an explicit --device <name> still resolves above).
254
+ .filter((d) => !isControlDevice(d))
252
255
  .map((d) => ({
253
256
  name: d.name,
254
257
  sshTarget: d.name,
@@ -507,33 +507,45 @@ export function registerRunCommand(program) {
507
507
  process.exit(1);
508
508
  }
509
509
  const hostName = hostGiven[0];
510
- const { resolveHost, resolveHostByCap } = await import('../lib/hosts/registry.js');
511
- const { dispatchToHost, runInteractiveOnHost } = await import('../lib/hosts/dispatch.js');
512
- const { registerHostSession, registerInteractiveHostSession } = await import('../lib/hosts/session-index.js');
510
+ const { resolveHostRunTarget, dispatchPromptToHost, HostResolutionError } = await import('../lib/hosts/run-target.js');
511
+ const { runInteractiveOnHost } = await import('../lib/hosts/dispatch.js');
512
+ const { registerInteractiveHostSession } = await import('../lib/hosts/session-index.js');
513
+ const { RUN_OPTION_REJECT_MESSAGES } = await import('../lib/hosts/remote-cmd.js');
513
514
  const { normalizeRunStrategy, RUN_STRATEGIES } = await import('../lib/rotate.js');
514
- // A password-auth device throws DeviceOffloadUnsupportedError here; it's
515
- // printed cleanly by the top-level catch in index.ts (covers every
516
- // resolveHost caller), so it never falls through to capability routing.
517
- let host = await resolveHost(hostName);
518
- if (!host) {
519
- // Not a host name — try capability routing (e.g. --host gpu). A
520
- // "Multiple hosts tagged…" error is actionable and must surface;
521
- // only "no host tagged" falls through to the generic unknown-host msg.
522
- try {
523
- host = await resolveHostByCap(hostName, options.any);
524
- }
525
- catch (e) {
526
- const msg = e.message ?? '';
527
- if (msg.startsWith('Multiple hosts')) {
528
- console.error(chalk.red(msg));
529
- process.exit(1);
530
- }
531
- }
532
- }
533
- if (!host) {
534
- console.error(chalk.red(`Unknown host "${hostName}". List hosts: agents hosts list`));
515
+ // The forwarding contract (RUN_OPTION_FORWARDING): options that cannot
516
+ // cross the SSH boundary fail loud BEFORE dispatch never a silent
517
+ // drop. Value-aware: only reject what was actually passed.
518
+ const hostRejects = [];
519
+ if (options.secrets.length > 0)
520
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.secrets);
521
+ if (options.secretsKeys)
522
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.secretsKeys);
523
+ if (options.allowExpired)
524
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.allowExpired);
525
+ if (options.resumeCheckpoint)
526
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeCheckpoint);
527
+ if (options.resume === true)
528
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeBare);
529
+ if (hostRejects.length > 0) {
530
+ for (const msg of hostRejects)
531
+ console.error(chalk.red(msg));
535
532
  process.exit(1);
536
533
  }
534
+ // Shared resolution (name → capability tag → error). A password-auth
535
+ // device throws DeviceOffloadUnsupportedError inside the helper and
536
+ // propagates untouched — it's printed cleanly by the top-level catch in
537
+ // index.ts (covers every resolveHost caller).
538
+ let host;
539
+ try {
540
+ host = await resolveHostRunTarget(hostName, { any: options.any });
541
+ }
542
+ catch (e) {
543
+ if (e instanceof HostResolutionError) {
544
+ console.error(chalk.red(e.message));
545
+ process.exit(1);
546
+ }
547
+ throw e;
548
+ }
537
549
  try {
538
550
  const [runAgent, rawRunVersion] = agentSpec.split('@');
539
551
  // Forward the explicit @version pin verbatim. Resolving aliases like
@@ -636,10 +648,12 @@ export function registerRunCommand(program) {
636
648
  agent: runAgent,
637
649
  version: resumeId ? undefined : runVersion,
638
650
  strategy: resumeId ? undefined : runStrategy,
651
+ fallback: options.fallback,
639
652
  prompt,
640
653
  mode: options.mode,
641
654
  model: options.model,
642
655
  effort: options.effort,
656
+ env: options.env,
643
657
  addDir: hostAddDirs,
644
658
  json: options.json,
645
659
  verbose: options.verbose,
@@ -663,32 +677,37 @@ export function registerRunCommand(program) {
663
677
  console.error(chalk.red('A prompt is required for headless host runs: agents run <agent> "<task>" --host <name>'));
664
678
  process.exit(1);
665
679
  }
666
- const hostSessionId = runAgent === 'claude' && !resumeId ? randomUUID() : undefined;
667
- const { task, exitCode } = await dispatchToHost(host, {
680
+ // Session-id mint, detached dispatch, and local session-index
681
+ // registration all live in the shared helper (lib/hosts/run-target.ts).
682
+ const { task, exitCode } = await dispatchPromptToHost(host, {
668
683
  agent: runAgent,
669
684
  version: resumeId ? undefined : runVersion,
670
685
  strategy: resumeId ? undefined : runStrategy,
686
+ fallback: options.fallback,
671
687
  prompt,
672
688
  mode: options.mode,
673
689
  model: options.model,
674
690
  effort: options.effort,
691
+ env: options.env,
675
692
  addDir: hostAddDirs,
693
+ timeout: options.timeout,
694
+ loop: options.loop,
695
+ maxIterations: options.maxIterations,
696
+ budget: options.budget,
697
+ until: options.until,
698
+ interval: options.interval,
676
699
  json: options.json,
677
700
  verbose: options.verbose,
678
- timeout: options.timeout,
679
701
  yes: options.yes,
680
702
  acp: options.acp,
703
+ autoSecrets: options.autoSecrets,
681
704
  remoteCwd: hostCwd,
682
- sessionId: hostSessionId,
683
705
  name: options.name,
684
706
  resume: resumeId,
685
707
  follow: options.follow !== false,
708
+ passthroughArgs,
686
709
  copyCreds: hostCopyCreds,
687
710
  });
688
- // Register the dispatched run in the LOCAL session index so it shows
689
- // up in `agents sessions` and resolves by id/name, even though its
690
- // transcript lives on the host. No-op when no session id was captured.
691
- registerHostSession(task, { cwd: process.cwd(), prompt });
692
711
  if (options.follow === false) {
693
712
  // The handle the caller uses to check on the run: the name if given,
694
713
  // else the real host-task id (never the old literal `<id>`). Steer
@@ -803,7 +822,7 @@ export function registerRunCommand(program) {
803
822
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
804
823
  process.exit(resumeExit);
805
824
  }
806
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
825
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported, isHeadlessSecretsContext }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
807
826
  import('../lib/exec.js'),
808
827
  import('../lib/agents.js'),
809
828
  import('../lib/profiles.js'),
@@ -841,6 +860,11 @@ export function registerRunCommand(program) {
841
860
  // produced item, and drives the teams supervisor to drain — no `teams`
842
861
  // subcommands needed.
843
862
  let workflowForEach;
863
+ // True once this run copies ≥1 dispatchable subagent into the shared agents
864
+ // dir. Used below to keep the `Task` tool in a `tools:`-restricted workflow —
865
+ // an orchestrator handed subagents but denied `Task` cannot reach them and
866
+ // degenerates to a no-op ("I'll wait for the completion notification").
867
+ let workflowHasSubagents = false;
844
868
  const cwd = options.cwd ?? process.cwd();
845
869
  if (isValidAgent(rawAgent)) {
846
870
  agent = rawAgent;
@@ -922,6 +946,8 @@ export function registerRunCommand(program) {
922
946
  workflowSubagentTargets.push(dest);
923
947
  copied++;
924
948
  }
949
+ if (copied > 0)
950
+ workflowHasSubagents = true;
925
951
  if (allowedAgents !== undefined) {
926
952
  // Surface any allowedAgents entry with no matching subagent file, and
927
953
  // report how many were filtered out, so the scope is auditable.
@@ -965,7 +991,9 @@ export function registerRunCommand(program) {
965
991
  }
966
992
  // Auto-inject secrets bundles declared in the workflow's frontmatter `secrets:` field.
967
993
  // Union with any --secrets flags the user passed; dedupe. Skip when --no-auto-secrets is set.
968
- if (!options.noAutoSecrets) {
994
+ // (Commander stores the negated flag as `autoSecrets: false` — the old
995
+ // `noAutoSecrets` read was never populated, making the flag a no-op.)
996
+ if (options.autoSecrets !== false) {
969
997
  const declared = workflowFrontmatter?.secrets ?? [];
970
998
  if (declared.length > 0) {
971
999
  const existing = new Set(options.secrets);
@@ -1002,8 +1030,14 @@ export function registerRunCommand(program) {
1002
1030
  }
1003
1031
  else if (hasScoping) {
1004
1032
  if (tools && tools.length > 0) {
1005
- workflowToolsRestrict = tools;
1006
- process.stderr.write(chalk.gray(`[workflow] restricting available tools to: ${tools.join(', ')} (Write/Bash/Edit unavailable unless listed)\n`));
1033
+ // An orchestrator with dispatchable subagents MUST retain the `Task`
1034
+ // tool, or it can't reach the subagents this run just installed for it
1035
+ // — the run silently no-ops ("I'll wait for the completion notification").
1036
+ workflowToolsRestrict = ensureSubagentDispatchTool(tools, workflowHasSubagents);
1037
+ process.stderr.write(chalk.gray(`[workflow] restricting available tools to: ${workflowToolsRestrict.join(', ')} (Write/Bash/Edit unavailable unless listed)\n`));
1038
+ if (workflowToolsRestrict.length !== tools.length) {
1039
+ process.stderr.write(chalk.gray(`[workflow] kept Task tool: workflow ships subagents to dispatch\n`));
1040
+ }
1007
1041
  }
1008
1042
  if (mcpServerNames && mcpServerNames.length > 0) {
1009
1043
  const servers = getMcpServersByName(mcpServerNames, { cwd });
@@ -1186,8 +1220,12 @@ export function registerRunCommand(program) {
1186
1220
  process.exit(1);
1187
1221
  }
1188
1222
  const strategy = options.balanced ? 'balanced' : explicitStrategy ?? configuredStrategy;
1189
- // Strategy only applies to bare agent invocations. Explicit @version,
1190
- // profiles, and fallback chains already define their execution target.
1223
+ // Strategy only applies to bare agent invocations. Explicit @version and
1224
+ // profiles already define their execution target. A --fallback chain does
1225
+ // NOT pin the primary: it only names where to cascade on a rate limit, so
1226
+ // the bare primary still resolves through the strategy — otherwise every
1227
+ // `agents run claude --fallback codex` run lands on the pinned default
1228
+ // account and account rotation silently stops (the gh-monitor heal bug).
1191
1229
  if (strategy !== 'pinned' || options.balanced || explicitStrategy) {
1192
1230
  if (version) {
1193
1231
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
@@ -1195,9 +1233,6 @@ export function registerRunCommand(program) {
1195
1233
  else if (fromProfile) {
1196
1234
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: profile pins its own version/auth\n`));
1197
1235
  }
1198
- else if (options.fallback) {
1199
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: --fallback pins versions directly\n`));
1200
- }
1201
1236
  else {
1202
1237
  try {
1203
1238
  const resolved = await resolveRunVersion(agent, strategy, cwd);
@@ -1350,6 +1385,10 @@ export function registerRunCommand(program) {
1350
1385
  caller: `agent ${agent}`,
1351
1386
  keys: secretsKeysSubset,
1352
1387
  allowExpired: options.allowExpired,
1388
+ // A headless/background run (routine, teammate, detached) must not
1389
+ // pop a Touch ID sheet nobody can answer — resolve broker-only and
1390
+ // fail fast with an actionable error. Interactive runs still prompt.
1391
+ agentOnly: isHeadlessSecretsContext(),
1353
1392
  });
1354
1393
  const entries = describeBundle(bundle);
1355
1394
  const counts = {};
@@ -1470,17 +1509,23 @@ export function registerRunCommand(program) {
1470
1509
  // path (continuing the session via /continue). Because this injects into
1471
1510
  // the same `fallback` array `--fallback` uses, it must only arm for run
1472
1511
  // shapes that accept a fallback chain — shouldArmRotationFailover excludes
1473
- // acp/loop/resume-checkpoint (which reject a non-empty fallback below),
1474
- // interactive/no-prompt runs, and runs that already have an explicit or
1475
- // profile fallback. Pinned/single-account runs stay unchanged because
1476
- // rotationResult is null or rotationFailoverChain returns []. version is
1477
- // set here because rotationResult is only populated when resolveRunVersion
1478
- // picked one.
1512
+ // acp/loop/resume-checkpoint (which reject a non-empty fallback below)
1513
+ // and interactive/no-prompt runs. Pinned/single-account runs stay
1514
+ // unchanged because rotationResult is null or rotationFailoverChain
1515
+ // returns []. version is set here because rotationResult is only
1516
+ // populated when resolveRunVersion picked one.
1517
+ //
1518
+ // Composes with an explicit --fallback chain: the same-agent accounts are
1519
+ // UNSHIFTED ahead of the user's cross-agent entries, so a rate limit
1520
+ // first tries the other accounts of the same agent (cheapest recovery —
1521
+ // same CLI, session continues) and only then cascades to codex/gemini/etc.
1522
+ // Profiles never compose: strategy is skipped for them, rotationResult
1523
+ // stays null. (fromProfile's model-swap unshift above is therefore never
1524
+ // displaced by this one.)
1479
1525
  if (shouldArmRotationFailover({
1480
1526
  hasRotation: !!rotationResult,
1481
1527
  hasVersion: !!version,
1482
1528
  hasPrompt: prompt !== undefined,
1483
- explicitFallback: fallback.length > 0,
1484
1529
  interactive: !!options.interactive,
1485
1530
  acp: !!options.acp,
1486
1531
  loop: !!options.loop,
@@ -1488,7 +1533,7 @@ export function registerRunCommand(program) {
1488
1533
  })) {
1489
1534
  const failover = rotationFailoverChain(rotationResult, version);
1490
1535
  if (failover.length > 0) {
1491
- fallback.push(...failover);
1536
+ fallback.unshift(...failover);
1492
1537
  if (!options.quiet) {
1493
1538
  const accounts = failover.map(f => `${f.agent}@${f.version}`).join(', ');
1494
1539
  process.stderr.write(chalk.gray(`[agents] rate-limit failover armed: ${accounts}\n`));
@@ -8,6 +8,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
8
8
  import { loadPolicy, applyPolicyToBlock, isPhoneUrgent } from '../lib/feed-policy.js';
9
9
  import { notifyUrgentBlock } from '../lib/notify.js';
10
10
  import { gcMailbox } from '../lib/mailbox-gc.js';
11
+ import { isValidMailboxId } from '../lib/mailbox.js';
11
12
  import { getActiveSessions } from '../lib/session/active.js';
12
13
  import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
13
14
  import { GLYPH, masthead } from '../lib/comms-render.js';
@@ -38,6 +39,11 @@ export function parseRemoteFeed(stdout, machine) {
38
39
  const block = item;
39
40
  if (!block.blockId || !block.sessionId || !block.mailboxId || !block.questions?.length)
40
41
  continue;
42
+ // A crafted mailboxId (path separators, `.`/`..`) would throw inside
43
+ // mailboxDir() when policy runs against the block, aborting the whole
44
+ // dispatch loop — drop it here so a malicious peer can't smuggle one in.
45
+ if (!isValidMailboxId(block.mailboxId))
46
+ continue;
41
47
  blocks.push({ ...block, host: machine });
42
48
  }
43
49
  return blocks;
@@ -240,20 +246,28 @@ export function registerFeedCommand(program) {
240
246
  const policy = loadPolicy();
241
247
  const now = new Date();
242
248
  for (const b of blocks) {
243
- const result = applyPolicyToBlock(b, policy, now);
244
- if (result.action !== 'none') {
245
- console.log(`${chalk.yellow('policy')} ${b.blockId}: ${result.action}`);
246
- }
247
- if (isPhoneUrgent(b, policy)) {
248
- const notifyResult = await notifyUrgentBlock(b, { dryRun: opts.json });
249
- if (notifyResult.ok && !notifyResult.skipped) {
250
- recordNotified(b.blockId);
251
- console.log(`${chalk.green('notified')} ${b.blockId}`);
249
+ // Wrap per-block policy so one malformed block (e.g. a crafted
250
+ // mailboxId that throws in mailboxDir) can't abort the whole loop and
251
+ // strand every remaining block's dispatch.
252
+ try {
253
+ const result = applyPolicyToBlock(b, policy, now);
254
+ if (result.action !== 'none') {
255
+ console.log(`${chalk.yellow('policy')} ${b.blockId}: ${result.action}`);
252
256
  }
253
- else if (notifyResult.error) {
254
- console.error(chalk.yellow(`Notification failed for ${b.blockId}: ${notifyResult.error}`));
257
+ if (isPhoneUrgent(b, policy)) {
258
+ const notifyResult = await notifyUrgentBlock(b, { dryRun: opts.json });
259
+ if (notifyResult.ok && !notifyResult.skipped) {
260
+ recordNotified(b.blockId);
261
+ console.log(`${chalk.green('notified')} ${b.blockId}`);
262
+ }
263
+ else if (notifyResult.error) {
264
+ console.error(chalk.yellow(`Notification failed for ${b.blockId}: ${notifyResult.error}`));
265
+ }
255
266
  }
256
267
  }
268
+ catch (err) {
269
+ console.error(chalk.yellow(`Skipped block ${b.blockId}: ${err.message}`));
270
+ }
257
271
  }
258
272
  }
259
273
  for (const warning of setupWarnings) {
@@ -11,6 +11,7 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
11
11
  import { checkbox, confirm } from '@inquirer/prompts';
12
12
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
13
13
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
14
+ import { getDevice } from '../lib/devices/registry.js';
14
15
  import { sshTargetFor } from '../lib/hosts/types.js';
15
16
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
17
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
@@ -93,7 +94,7 @@ async function doAdd(name, target, opts) {
93
94
  return;
94
95
  }
95
96
  let spec;
96
- let sshTarget;
97
+ let sshTarget = '';
97
98
  if (target) {
98
99
  assertValidSshTarget(target);
99
100
  const { address, user } = parseTarget(target);
@@ -105,9 +106,36 @@ async function doAdd(name, target, opts) {
105
106
  sshTarget = name;
106
107
  }
107
108
  else {
108
- console.log(chalk.red(`"${name}" is not in ~/.ssh/config. Pass a target: agents hosts add ${name} <user@host>`));
109
- process.exitCode = 1;
110
- return;
109
+ // A registered device enrolls with no target connection details come from
110
+ // the device profile. The main reason to enroll one at all is the overlay
111
+ // metadata (capability tags); plain dispatch already works via the devices
112
+ // provider.
113
+ const device = await getDevice(name);
114
+ if (device && device.auth.method !== 'password') {
115
+ const address = device.address.dnsName ?? device.address.ip;
116
+ if (address) {
117
+ spec = {
118
+ name,
119
+ provider: 'local',
120
+ source: 'inline',
121
+ address,
122
+ user: device.user,
123
+ caps: opts.cap,
124
+ os: opts.os ?? (device.platform !== 'unknown' ? device.platform : undefined),
125
+ };
126
+ sshTarget = device.user ? `${device.user}@${address}` : address;
127
+ }
128
+ }
129
+ if (!spec) {
130
+ if (device?.auth.method === 'password') {
131
+ console.log(chalk.red(`Device "${name}" uses password auth — dispatch needs key auth. Switch it first: agents devices set ${name} --auth key`));
132
+ }
133
+ else {
134
+ console.log(chalk.red(`"${name}" is not in ~/.ssh/config or the devices registry. Pass a target: agents hosts add ${name} <user@host>`));
135
+ }
136
+ process.exitCode = 1;
137
+ return;
138
+ }
111
139
  }
112
140
  const probe = probeHost(sshTarget);
113
141
  if (!spec.os && probe.os)
@@ -133,8 +161,12 @@ async function doList(json) {
133
161
  // Cap TARGET at its column so a long user@host can't shove CAPS out of alignment.
134
162
  const tgtW = stringWidth(tgt);
135
163
  const tgtCol = tgtW > 28 ? truncateToWidth(tgt, 28) : tgt + ' '.repeat(28 - tgtW);
136
- const mark = h.enrolled ? '' : chalk.gray(' ·available');
137
- console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
164
+ // Devices surface their registry as SOURCE (they're synced, not enrolled here).
165
+ const source = h.provider === 'devices' ? 'devices' : h.source;
166
+ const mark = h.dispatchable === false
167
+ ? chalk.yellow(' ·password-auth (no dispatch)')
168
+ : h.enrolled ? '' : chalk.gray(' ·available');
169
+ console.log(h.name.padEnd(20) + source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
138
170
  }
139
171
  }
140
172
  async function doCheck(name) {
@@ -163,6 +195,12 @@ async function doRemove(name) {
163
195
  console.log(chalk.yellow(`"${name}" is not enrolled (nothing to remove).`));
164
196
  return;
165
197
  }
198
+ if (host.provider === 'devices') {
199
+ // The devices registry owns this entry — hosts remove would silently no-op.
200
+ console.log(chalk.yellow(`"${name}" comes from the devices registry. Remove it there: agents devices rm ${name}`));
201
+ process.exitCode = 1;
202
+ return;
203
+ }
166
204
  await getProvider('local').remove(name);
167
205
  console.log(chalk.green(`Removed ${name}`));
168
206
  }
@@ -6,9 +6,9 @@ import { capableAgents, isCapable } from '../lib/capabilities.js';
6
6
  import { emit } from '../lib/events.js';
7
7
  import { AGENTS, getAllCliStates, resolveAgentName, formatAgentError, registerMcpToTargets, unregisterMcpFromTargets, listInstalledMcpsWithScope, parseMcpConfig, getMcpConfigPathForHome, agentLabel, } from '../lib/agents.js';
8
8
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
9
- import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
9
+ import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, isProjectMcpTrusted, trustProjectMcp, untrustProjectMcp, getMcpTrustStorePath, } from '../lib/mcp.js';
10
10
  import { cloneRepo } from '../lib/git.js';
11
- import { getMcpDir } from '../lib/state.js';
11
+ import { getMcpDir, getProjectAgentsDir } from '../lib/state.js';
12
12
  import { getEffectiveHome, getGlobalDefault, listInstalledVersions, getVersionHomePath, resolveInstalledAgentTargets, resolveConfiguredAgentTargets, syncResourcesToVersion, } from '../lib/versions.js';
13
13
  import { getUserAgentsDir } from '../lib/state.js';
14
14
  import { isPromptCancelled, isInteractiveTerminal, requireInteractiveSelection, promptRemovalTargets, parseCommaSeparatedList, ensureAgentVersionsInstalled, resolveAgentTargetsAutoInstalling, resolveInstalledAgentTargetsAutoInstalling, VersionNotInstalledError, resolveListFilterOrExit, } from './utils.js';
@@ -178,6 +178,43 @@ When to use:
178
178
  filterVersion,
179
179
  });
180
180
  });
181
+ mcpCmd
182
+ .command('trust')
183
+ .description('Trust this project so its .agents/mcp/ servers may be registered and spawned')
184
+ .action(() => {
185
+ const projectAgentsDir = getProjectAgentsDir();
186
+ if (!projectAgentsDir) {
187
+ console.log(chalk.red('Not inside a project (no .agents/ directory found) — nothing to trust.'));
188
+ process.exit(1);
189
+ }
190
+ const servers = listMcpServerConfigs().filter((s) => s.scope === 'project');
191
+ if (servers.length > 0) {
192
+ console.log(chalk.bold('Project-scoped MCP servers that will be trusted to run:'));
193
+ for (const s of servers) {
194
+ console.log(' ' + chalk.cyan(s.name) + ' ' + chalk.white(formatCentralCommand(s)));
195
+ }
196
+ }
197
+ const trusted = trustProjectMcp();
198
+ console.log(chalk.green(`Trusted project MCP servers for: ${trusted}`));
199
+ console.log(chalk.gray(`Recorded in ${getMcpTrustStorePath()} — revoke with: agents mcp untrust`));
200
+ });
201
+ mcpCmd
202
+ .command('untrust')
203
+ .description('Revoke MCP trust for this project (its .agents/mcp/ servers stop auto-applying)')
204
+ .action(() => {
205
+ const projectAgentsDir = getProjectAgentsDir();
206
+ if (!projectAgentsDir) {
207
+ console.log(chalk.red('Not inside a project (no .agents/ directory found).'));
208
+ process.exit(1);
209
+ }
210
+ const removed = untrustProjectMcp();
211
+ if (removed) {
212
+ console.log(chalk.green('Revoked MCP trust for this project.'));
213
+ }
214
+ else {
215
+ console.log(chalk.gray('This project was not trusted — nothing to revoke.'));
216
+ }
217
+ });
181
218
  mcpCmd
182
219
  .command('add <name> [command_or_url...]')
183
220
  .description('Add an MCP server to the manifest (run "agents mcp register" afterward to apply)')
@@ -797,9 +834,15 @@ function formatCentralCommand(server) {
797
834
  function formatMcpDetail(name, source, centralConfig, manifestConfig, command, targets) {
798
835
  const lines = [];
799
836
  lines.push(chalk.bold.cyan(name));
800
- const tag = source === 'central' ? chalk.green('declared in ~/.agents/mcp/') :
801
- source === 'manifest' ? chalk.gray('declared in agents.yaml') :
802
- chalk.yellow('unmanaged (not in central or manifest)');
837
+ const isUntrustedProject = centralConfig?.scope === 'project'
838
+ && !isProjectMcpTrusted(getProjectAgentsDir() ?? '');
839
+ const tag = centralConfig?.scope === 'project'
840
+ ? (isUntrustedProject
841
+ ? chalk.yellow('declared in <repo>/.agents/mcp/ (untrusted project — will NOT auto-apply)')
842
+ : chalk.green('declared in <repo>/.agents/mcp/ (trusted project)')) :
843
+ source === 'central' ? chalk.green('declared in ~/.agents/mcp/') :
844
+ source === 'manifest' ? chalk.gray('declared in agents.yaml') :
845
+ chalk.yellow('unmanaged (not in central or manifest)');
803
846
  lines.push(' ' + tag);
804
847
  lines.push('');
805
848
  if (centralConfig) {
@@ -814,6 +857,13 @@ function formatMcpDetail(name, source, centralConfig, manifestConfig, command, t
814
857
  if (command) {
815
858
  lines.push(` ${chalk.gray('command:')} ${chalk.white(command)}`);
816
859
  }
860
+ // A project-scoped server is an arbitrary command from a (possibly cloned)
861
+ // repo. Show exactly what would run and how to opt in (RUSH-1776).
862
+ if (isUntrustedProject) {
863
+ lines.push('');
864
+ lines.push(' ' + chalk.yellow('This project is untrusted — its MCP servers are skipped on sync and workflow runs.'));
865
+ lines.push(' ' + chalk.gray('Review the command above, then opt in with: ') + chalk.white('agents mcp trust'));
866
+ }
817
867
  lines.push('');
818
868
  lines.push(chalk.bold(' Synced to'));
819
869
  lines.push(buildTargetsSection(targets));
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Monitors — durable event-triggered watchers.
3
+ *
4
+ * Registers the `agents monitors` command tree: create, list, view, dry-run test,
5
+ * edit, logs, fire history, pause/resume, (re)pin the owner device, and remove.
6
+ * Mirrors `agents routines` (commands/routines.ts) and shares the same daemon +
7
+ * dispatch engine underneath — a monitor is a routine whose trigger is a watched
8
+ * source instead of a clock.
9
+ */
10
+ import type { Command } from 'commander';
11
+ /** Register the `agents monitors` command tree. */
12
+ export declare function registerMonitorsCommands(program: Command): void;