@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -20,8 +20,9 @@ import { sshResolve } from '../lib/hosts/ssh-config.js';
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
22
  import * as os from 'os';
23
- import { randomUUID } from 'crypto';
24
23
  import { spawnSync } from 'child_process';
24
+ import { randomUUID } from 'crypto';
25
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
25
26
  /** Distinguish a terminal account-picker marker from an explicit @version pin. */
26
27
  export function parseRunAccountPickerRequest(agentSpec) {
27
28
  const requested = agentSpec.endsWith('@');
@@ -382,8 +383,8 @@ export function registerRunCommand(program) {
382
383
  .option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
383
384
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
384
385
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
385
- .option('--host <name>', 'Offload this run onto another machine over SSH instead of running locally — a device, a registered agent host, or user@host. See `agents devices` / `agents hosts`.')
386
- .option('--device <name>', 'Alias of --host: offload this run onto a registered device (from `agents devices`).')
386
+ .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick from 14d usage affinity (most-used online device has highest probability). See `agents devices`.')
387
+ .option('--device <name>', 'Alias of --host. Pass "auto" for affinity-based device pick (same as --host auto).')
387
388
  .option('--remote-cwd <dir>', "Explicit host working directory for --host runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
388
389
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
389
390
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
@@ -398,6 +399,8 @@ export function registerRunCommand(program) {
398
399
  // `--on` and `--computer` are hidden aliases of `--host` — same behavior.
399
400
  runCmd.addOption(new Option('--on <name>', 'Alias of --host.').hideHelp());
400
401
  runCmd.addOption(new Option('--computer <name>', 'Alias of --host.').hideHelp());
402
+ // Deprecated one-release alias: `agents run … --smart` → treat as `--device auto`.
403
+ runCmd.addOption(new Option('--smart', 'Deprecated: use --device auto (affinity host pick).').hideHelp());
401
404
  // Internal: the `--host` dispatch forwards this so the REMOTE run prints its
402
405
  // resolved session id as a one-line stdout sentinel (hosts/session-marker.ts),
403
406
  // letting the launcher relate the remote-created session back to itself for
@@ -494,11 +497,14 @@ export function registerRunCommand(program) {
494
497
  // their existing meaning in every dispatch path below.
495
498
  const accountPicker = parseRunAccountPickerRequest(agentSpec);
496
499
  const accountPickerRequested = accountPicker.requested;
497
- const normalizedAgentSpec = accountPicker.normalizedAgentSpec;
500
+ let normalizedAgentSpec = accountPicker.normalizedAgentSpec;
498
501
  if (!accountPicker.valid) {
499
502
  console.error(chalk.red(`Invalid account picker target: ${agentSpec}. Use agents run <agent>@.`));
500
503
  process.exit(1);
501
504
  }
505
+ // Account-picker conflict check runs BEFORE device=auto may set balanced,
506
+ // so an implicit balanced preference never surfaces as a fake
507
+ // "cannot be combined with --balanced" when the user only typed trailing @.
502
508
  if (accountPickerRequested) {
503
509
  const conflicts = runAccountPickerConflicts(options);
504
510
  if (conflicts.length > 0) {
@@ -507,6 +513,27 @@ export function registerRunCommand(program) {
507
513
  process.exit(1);
508
514
  }
509
515
  }
516
+ // --device auto / --host auto (and deprecated --smart): affinity-pick host.
517
+ // Harness is always the agent the user typed — never auto-picked.
518
+ // Affinity failure degrades to local (does not kill the run).
519
+ {
520
+ const { applyDeviceAutoToOptions } = await import('../lib/smart-launch.js');
521
+ const result = applyDeviceAutoToOptions(options, {
522
+ accountPickerRequested,
523
+ });
524
+ if (!options.quiet && result.deprecationSmart) {
525
+ process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
526
+ }
527
+ if (!options.quiet && result.skipped) {
528
+ process.stderr.write(chalk.yellow(`[agents] device=auto skipped: ${result.skipped} (running local)\n`));
529
+ }
530
+ if (!options.quiet && result.banner) {
531
+ const { hostLabel, deviceHint, acctNote } = result.banner;
532
+ process.stderr.write(chalk.gray(`[agents] device=auto → ${hostLabel}` +
533
+ (deviceHint ? ` (affinity ${deviceHint})` : '') +
534
+ ` · ${acctNote}\n`));
535
+ }
536
+ }
510
537
  // --lease: invent a disposable cloud box for this run (via crabbox), run
511
538
  // the agent there, then tear it down. --box: reuse a named warm crabbox
512
539
  // box, run the same bootstrap there, and leave the box running.
@@ -877,7 +904,7 @@ export function registerRunCommand(program) {
877
904
  process.exit(1);
878
905
  }
879
906
  const hostName = hostGiven[0];
880
- const { resolveHostRunTarget, dispatchPromptToHost, HostResolutionError } = await import('../lib/hosts/run-target.js');
907
+ const { resolveHostRunTarget, resolveHostSessionId, dispatchPromptToHost, HostResolutionError } = await import('../lib/hosts/run-target.js');
881
908
  const { runInteractiveOnHost } = await import('../lib/hosts/dispatch.js');
882
909
  const { registerInteractiveHostSession } = await import('../lib/hosts/session-index.js');
883
910
  const { RUN_OPTION_REJECT_MESSAGES } = await import('../lib/hosts/remote-cmd.js');
@@ -1035,10 +1062,21 @@ export function registerRunCommand(program) {
1035
1062
  process.exit(1);
1036
1063
  }
1037
1064
  // Mirror the local path (lib/exec.ts): only Claude accepts a forced
1038
- // `--session-id`. Generating it here lets us register the run in the
1039
- // local index and makes it resumable by id. On resume the remote
1040
- // session keeps its existing id don't mint a new one.
1041
- const hostSessionId = runAgent === 'claude' && !resumeId ? randomUUID() : undefined;
1065
+ // `--session-id`. Adopt the caller's id when present; otherwise mint
1066
+ // one here. Registering that same id keeps the local index aligned
1067
+ // with the remote agent. On resume, don't mint a new one.
1068
+ const hostSessionId = resolveHostSessionId(runAgent, resumeId, options.sessionId);
1069
+ // For every OTHER agent the remote coins its own id, which we can't
1070
+ // know up front. Forward a launch id we control as AGENT_LAUNCH_ID:
1071
+ // the remote `agents run` adopts it (exec.ts resolveLaunchId) and its
1072
+ // SessionStart hook records the real id under that exact key, so after
1073
+ // the stream we resolve the id by one ssh read of the remote hook
1074
+ // record — the same launch-id join used locally (RUSH-2034). Not
1075
+ // needed for Claude (id forced) or resume (id already known).
1076
+ const correlationLaunchId = !hostSessionId && !resumeId && isSessionTrackedAgent(runAgent) ? randomUUID() : undefined;
1077
+ const hostEnv = correlationLaunchId
1078
+ ? [...options.env, `AGENT_LAUNCH_ID=${correlationLaunchId}`]
1079
+ : options.env;
1042
1080
  if (hostSessionId) {
1043
1081
  registerInteractiveHostSession({
1044
1082
  cwd: process.cwd(),
@@ -1058,7 +1096,7 @@ export function registerRunCommand(program) {
1058
1096
  mode: options.mode,
1059
1097
  model: options.model,
1060
1098
  effort: options.effort,
1061
- env: options.env,
1099
+ env: hostEnv,
1062
1100
  addDir: hostAddDirs,
1063
1101
  json: options.json,
1064
1102
  verbose: options.verbose,
@@ -1074,14 +1112,41 @@ export function registerRunCommand(program) {
1074
1112
  forceInteractive: options.interactive,
1075
1113
  copyCreds: hostCopyCreds,
1076
1114
  });
1115
+ // Resolve a non-Claude agent's REAL remote session id now the run has
1116
+ // booted (its hook has fired on the peer): one ssh read of the remote
1117
+ // hook record, keyed by the launch id we forwarded. Register it so the
1118
+ // run shows in `agents sessions` and can be reconnected/focused —
1119
+ // closing the non-Claude gap RUSH-2033 left. Best-effort: an
1120
+ // unreachable host or a not-yet-landed record leaves the run un-mapped
1121
+ // rather than mis-mapped.
1122
+ let resolvedRemoteId;
1123
+ if (correlationLaunchId) {
1124
+ const { resolveRemoteSessionId } = await import('../lib/hosts/remote-session-id.js');
1125
+ const { sshTargetFor } = await import('../lib/hosts/types.js');
1126
+ try {
1127
+ resolvedRemoteId = resolveRemoteSessionId(sshTargetFor(host), correlationLaunchId);
1128
+ }
1129
+ catch {
1130
+ /* ssh read is best-effort — keep the run un-mapped, never guess */
1131
+ }
1132
+ if (resolvedRemoteId) {
1133
+ registerInteractiveHostSession({
1134
+ cwd: process.cwd(),
1135
+ host: host.name,
1136
+ agent: runAgent,
1137
+ sessionId: resolvedRemoteId,
1138
+ name: options.name,
1139
+ });
1140
+ }
1141
+ }
1077
1142
  // A network drop kills the local ssh client (exit 255) but the remote
1078
1143
  // agent survives in its detached tmux session. With a known session id
1079
- // (Claude, or a resumed run) and a tmux-hosted run, re-attach the live
1080
- // pane automatically instead of exiting the user never has to notice
1081
- // the drop and `agents sessions focus` by hand. `raw` runs aren't tmux
1082
- // wrapped, so there is nothing to reconnect to. Non-Claude id capture is
1083
- // tracked in RUSH-2007.
1084
- const reconnectId = hostSessionId ?? resumeId;
1144
+ // (Claude's forced id, a resumed run, or a non-Claude id we just
1145
+ // resolved from the remote hook record) and a tmux-hosted run,
1146
+ // re-attach the live pane automatically instead of exiting the user
1147
+ // never has to notice the drop and `agents sessions focus` by hand.
1148
+ // `raw` runs aren't tmux wrapped, so there is nothing to reconnect to.
1149
+ const reconnectId = hostSessionId ?? resolvedRemoteId ?? resumeId;
1085
1150
  if (reconnectId && !isRaw) {
1086
1151
  const { reconnectInteractiveSession, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1087
1152
  if (exitCode === SSH_CONN_FAILURE) {
@@ -1127,6 +1192,7 @@ export function registerRunCommand(program) {
1127
1192
  remoteCwd: hostCwd,
1128
1193
  name: options.name,
1129
1194
  resume: resumeId,
1195
+ sessionId: options.sessionId,
1130
1196
  follow: options.follow !== false,
1131
1197
  passthroughArgs,
1132
1198
  copyCreds: hostCopyCreds,
@@ -1883,11 +1949,13 @@ export function registerRunCommand(program) {
1883
1949
  else {
1884
1950
  const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, {
1885
1951
  caller: `agent ${agent}`,
1952
+ agent,
1886
1953
  keys: secretsKeysSubset,
1887
1954
  allowExpired: options.allowExpired,
1888
- // A headless/background run (routine, teammate, detached) must not
1889
- // pop a Touch ID sheet nobody can answer resolve broker-only and
1890
- // fail fast with an actionable error. Interactive runs still prompt.
1955
+ // The harness identity scopes any cached grant. It no longer lets the
1956
+ // requesting agent wait for interactive approvalan agent launch
1957
+ // resolves broker-only and fails fast naming
1958
+ // `agents secrets unlock <bundle>` (bundles.ts:interactiveUnlock).
1891
1959
  agentOnly: isHeadlessSecretsContext(),
1892
1960
  });
1893
1961
  const entries = describeBundle(bundle);
@@ -856,6 +856,10 @@ export function registerRepoCommands(program) {
856
856
  }
857
857
  else {
858
858
  spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
859
+ // A failed repo must fail the command. Without this, `agents fleet run
860
+ // "agents repo pull user"` reported 11 ok across a fleet that pulled
861
+ // nothing — the silence that hid RUSH-2056. Matches commands/sync.ts.
862
+ process.exitCode = 1;
859
863
  }
860
864
  }
861
865
  // RUSH-1980: a pull rewrites the routine YAML on disk, but the daemon's
@@ -911,6 +915,10 @@ export function registerRepoCommands(program) {
911
915
  }
912
916
  else {
913
917
  spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
918
+ // A failed repo must fail the command. Without this, `agents fleet run
919
+ // "agents repo push user"` reported ok across a fleet that pushed
920
+ // nothing — the silence that hid RUSH-2056. Matches commands/sync.ts.
921
+ process.exitCode = 1;
914
922
  }
915
923
  }
916
924
  });
@@ -12,7 +12,8 @@ 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, checkJobDeviceEligibility, normalizeTriggerEvent, } from '../lib/routines.js';
15
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
16
+ import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
16
17
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
17
18
  import { getRoutinesDir } from '../lib/state.js';
18
19
  import { IS_WINDOWS } from '../lib/platform/index.js';
@@ -131,6 +132,24 @@ function ensureSchedulerRunning(opts = {}) {
131
132
  function writeJson(payload) {
132
133
  process.stdout.write(JSON.stringify(payload) + '\n');
133
134
  }
135
+ function printSyncResult(sync) {
136
+ console.log(chalk.bold(displayProjectPath(sync.projectRoot)));
137
+ if (sync.synced.length === 0 && sync.skipped.length === 0 && sync.removed.length === 0 && sync.errors.length === 0) {
138
+ console.log(chalk.gray(' (no project routines)'));
139
+ }
140
+ if (sync.synced.length > 0) {
141
+ console.log(chalk.green(` synced: ${sync.synced.join(', ')}`));
142
+ }
143
+ if (sync.removed.length > 0) {
144
+ console.log(chalk.gray(` removed: ${sync.removed.join(', ')}`));
145
+ }
146
+ for (const s of sync.skipped) {
147
+ console.log(chalk.yellow(` skipped ${s.name}: ${s.reason}`));
148
+ }
149
+ for (const e of sync.errors) {
150
+ console.log(chalk.red(` error ${e.name}: ${e.error}`));
151
+ }
152
+ }
134
153
  function runMetaJson(run) {
135
154
  return {
136
155
  jobId: run.jobName,
@@ -233,6 +252,14 @@ export function registerRoutinesCommands(program) {
233
252
  # Create from YAML (for complex routines with multiple settings)
234
253
  agents routines add weekly-report.yml
235
254
 
255
+ # Place the job body on a fleet device / cloud / named host (not --host)
256
+ agents routines add drain --schedule "0 3 * * *" --agent claude --placement fleet --prompt "Drain queue"
257
+ agents routines add review --schedule "0 9 * * 1" --agent claude --placement cloud --prompt "Review open PRs"
258
+
259
+ # Opt a project's .agents/routines/*.yml into daemon firing (never auto)
260
+ agents routines enable-project --yes
261
+ agents routines sync
262
+
236
263
  # List all routines and their next run times
237
264
  agents routines list
238
265
 
@@ -270,8 +297,10 @@ export function registerRoutinesCommands(program) {
270
297
  - Detached/daemon fires use the pre-flight pick only (next tick re-selects).
271
298
  - Diagnostic lines log which account was picked, which were skipped, and
272
299
  each failover hop: look for "[agents] routine <name>:" in the run log.
273
- - Headless Claude auth: store CLAUDE_CODE_OAUTH_TOKEN in the 'claude'
274
- secrets bundle so the daemon can inject it into routine spawns.
300
+ - Claude auth: a routine authenticates through the pinned account's own
301
+ on-disk login (the same one 'agents run claude' uses on this device).
302
+ The daemon injects no token; if that account's login has expired the
303
+ run is skipped up front with a re-login hint.
275
304
  `,
276
305
  });
277
306
  routinesCmd
@@ -323,6 +352,10 @@ export function registerRoutinesCommands(program) {
323
352
  timezone: job.timezone ?? null,
324
353
  devices: job.devices ?? [],
325
354
  host: job.host ?? null,
355
+ hostStrategy: resolveHostStrategy(job),
356
+ source: job.source ?? null,
357
+ sourceRepo: job.source?.repo ?? job.repo ?? null,
358
+ sourceBranch: job.source?.branch ?? null,
326
359
  runsHere: jobRunsOnThisDevice(job),
327
360
  enabled: job.enabled,
328
361
  overdue: overdueSet.has(job.name),
@@ -368,7 +401,12 @@ export function registerRoutinesCommands(program) {
368
401
  }
369
402
  const latestRun = getLatestRun(job.name);
370
403
  const lastStatus = latestRun?.status || '-';
371
- const repoInfo = formatRepoLink(job.repo);
404
+ // Prefer project-source repo (with optional @branch) over bare job.repo.
405
+ const sourceRepo = job.source?.repo ?? job.repo;
406
+ const sourceLabel = sourceRepo
407
+ ? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
408
+ : null;
409
+ const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
372
410
  const repoCell = link(repoInfo.display, repoInfo.href);
373
411
  // Pad based on the display string, not the raw cell (which may include escape codes).
374
412
  const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
@@ -376,8 +414,16 @@ export function registerRoutinesCommands(program) {
376
414
  // chalk adds escape codes; pad the raw word and let chalk wrap it.
377
415
  const enabledWord = job.enabled ? 'yes' : 'no';
378
416
  const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
379
- // Placement (`→host`) rides in the Devices cell: eligibility →execution.
380
- const deviceFull = [job.devices?.join(',') ?? '', job.host ? `→${job.host}` : '']
417
+ // Placement rides in the Devices cell: eligibility →execution strategy.
418
+ const strategy = resolveHostStrategy(job);
419
+ const placementTag = strategy === 'local'
420
+ ? (job.host ? `→${job.host}` : '')
421
+ : strategy === 'host'
422
+ ? `→${job.host ?? '?'}`
423
+ : strategy === 'fleet'
424
+ ? '→fleet'
425
+ : '→cloud';
426
+ const deviceFull = [job.devices?.join(',') ?? '', placementTag]
381
427
  .filter(Boolean)
382
428
  .join(' ');
383
429
  const deviceWord = deviceFull.length === 0
@@ -423,7 +469,8 @@ export function registerRoutinesCommands(program) {
423
469
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
424
470
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
425
471
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
426
- .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
472
+ .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Sets hostStrategy=host. Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
473
+ .option('--placement <strategy>', `Where the job body runs: ${HOST_STRATEGIES.join('|')} (default: local, or host when --run-on is set). Not the same as --host (which manages routines on a remote machine).`)
427
474
  .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
428
475
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
429
476
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
@@ -490,12 +537,29 @@ export function registerRoutinesCommands(program) {
490
537
  if (options.devices !== undefined) {
491
538
  devices = await parseAndValidateDevices(options.devices);
492
539
  }
493
- // --run-on without a --devices pin would fire on EVERY daemon in the
494
- // fleet, each dispatching to the same host — duplicate runs. Pin to
495
- // this machine unless the user chose an explicit eligibility set.
496
- if (options.runOn && !devices) {
540
+ let hostStrategy;
541
+ try {
542
+ hostStrategy = parseHostStrategy(options.placement) ?? undefined;
543
+ }
544
+ catch (err) {
545
+ console.error(chalk.red(err.message));
546
+ process.exit(1);
547
+ }
548
+ // --run-on implies host strategy when the user didn't pick one.
549
+ if (options.runOn && !hostStrategy)
550
+ hostStrategy = 'host';
551
+ if (hostStrategy === 'host' && !options.runOn) {
552
+ console.error(chalk.red('--placement host requires --run-on <name>'));
553
+ process.exit(1);
554
+ }
555
+ // Off-box placement without a --devices pin would fire on EVERY daemon
556
+ // in the fleet, each dispatching once — duplicate runs (RUSH-1980).
557
+ // Pin to this machine unless the user chose an explicit eligibility set.
558
+ const strategyForPin = hostStrategy
559
+ ?? (options.runOn ? 'host' : 'local');
560
+ if (placementRequiresFiringPin(strategyForPin) && !devices) {
497
561
  devices = [machineId()];
498
- console.error(chalk.gray(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
562
+ console.error(chalk.gray(`--placement ${strategyForPin} with no --devices: pinned firing to this machine (${devices[0]}).`));
499
563
  }
500
564
  const config = {
501
565
  name: nameOrPath,
@@ -512,6 +576,7 @@ export function registerRoutinesCommands(program) {
512
576
  timezone: options.timezone,
513
577
  ...(devices ? { devices } : {}),
514
578
  ...(options.runOn ? { host: options.runOn } : {}),
579
+ ...(hostStrategy ? { hostStrategy } : {}),
515
580
  ...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
516
581
  ...(runOnce ? { runOnce: true } : {}),
517
582
  ...(options.endAt ? { endAt: options.endAt } : {}),
@@ -586,11 +651,12 @@ export function registerRoutinesCommands(program) {
586
651
  enabled: true,
587
652
  ...parsed,
588
653
  };
589
- // Same duplicate-fire guard as the --run-on flag: a host-placed routine
654
+ // Same duplicate-fire guard as --placement/--run-on: off-box placement
590
655
  // with no eligibility pin would fire from every daemon in the fleet.
591
- if (config.host && (!config.devices || config.devices.length === 0)) {
656
+ const fileStrategy = resolveHostStrategy(config);
657
+ if (placementRequiresFiringPin(fileStrategy) && (!config.devices || config.devices.length === 0)) {
592
658
  config.devices = [machineId()];
593
- console.error(chalk.gray(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
659
+ console.error(chalk.gray(`${fileStrategy} placement with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
594
660
  }
595
661
  writeJob(config);
596
662
  if (options.json) {
@@ -1120,6 +1186,12 @@ export function registerRoutinesCommands(program) {
1120
1186
  process.exit(1);
1121
1187
  }
1122
1188
  if (options.clear) {
1189
+ const strategy = resolveHostStrategy(job);
1190
+ if (placementRequiresFiringPin(strategy)) {
1191
+ console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1192
+ console.error(chalk.gray(`Keep a single-machine pin (e.g. --set ${machineId()}) or switch to --placement local.`));
1193
+ process.exit(1);
1194
+ }
1123
1195
  job.devices = undefined;
1124
1196
  writeJob(job);
1125
1197
  console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
@@ -1130,6 +1202,12 @@ export function registerRoutinesCommands(program) {
1130
1202
  if (hasSet) {
1131
1203
  const devices = await parseAndValidateDevices(options.set);
1132
1204
  job.devices = devices;
1205
+ // Re-validate so host/fleet/cloud can't be left with an empty set via --set ""
1206
+ const errs = validateJob(job);
1207
+ if (errs.length > 0) {
1208
+ console.error(chalk.red(errs.join('\n')));
1209
+ process.exit(1);
1210
+ }
1133
1211
  writeJob(job);
1134
1212
  console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
1135
1213
  if (isDaemonRunning())
@@ -1158,6 +1236,11 @@ export function registerRoutinesCommands(program) {
1158
1236
  })),
1159
1237
  });
1160
1238
  if (selected.length === 0) {
1239
+ const strategy = resolveHostStrategy(job);
1240
+ if (placementRequiresFiringPin(strategy)) {
1241
+ console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1242
+ return;
1243
+ }
1161
1244
  job.devices = undefined;
1162
1245
  writeJob(job);
1163
1246
  console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
@@ -1250,6 +1333,183 @@ export function registerRoutinesCommands(program) {
1250
1333
  console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
1251
1334
  }
1252
1335
  });
1336
+ routinesCmd
1337
+ .command('enable-project [path]')
1338
+ .description('Opt a project\'s .agents/routines/*.yml into daemon firing. Requires explicit approval — project routines never auto-fire from a cloned repo. Materialises copies into ~/.agents/routines/ with source provenance.')
1339
+ .option('--yes', 'Skip the interactive confirmation prompt')
1340
+ .option('--json', 'Emit machine-readable JSON')
1341
+ .action(async (projectPath, options) => {
1342
+ const root = projectPath
1343
+ ? path.resolve(projectPath)
1344
+ : resolveProjectRoot(process.cwd());
1345
+ if (!root) {
1346
+ console.error(chalk.red('No project .agents/ directory found from the current directory.'));
1347
+ console.error(chalk.gray('Run from inside a project, or pass the project path: agents routines enable-project /path/to/repo'));
1348
+ process.exit(1);
1349
+ }
1350
+ const files = listProjectRoutineFiles(root);
1351
+ if (files.length === 0) {
1352
+ console.error(chalk.red(`No routines found under ${path.join(root, '.agents', 'routines')}`));
1353
+ process.exit(1);
1354
+ }
1355
+ if (!options.yes) {
1356
+ if (!isInteractiveTerminal()) {
1357
+ console.error(chalk.red('Refusing to enable project routines non-interactively without --yes.'));
1358
+ console.error(chalk.gray(`Found ${files.length} routine(s) in ${displayProjectPath(root)}. Re-run with --yes to confirm.`));
1359
+ process.exit(1);
1360
+ }
1361
+ try {
1362
+ const { confirm } = await import('@inquirer/prompts');
1363
+ const ok = await confirm({
1364
+ message: `Enable daemon firing for ${files.length} project routine(s) in ${displayProjectPath(root)}?`,
1365
+ default: false,
1366
+ });
1367
+ if (!ok) {
1368
+ console.log(chalk.gray('Cancelled'));
1369
+ return;
1370
+ }
1371
+ }
1372
+ catch (err) {
1373
+ if (isPromptCancelled(err)) {
1374
+ console.log(chalk.gray('Cancelled'));
1375
+ return;
1376
+ }
1377
+ throw err;
1378
+ }
1379
+ }
1380
+ const newly = enableProjectRoutines(root);
1381
+ const sync = syncProjectRoutines(root);
1382
+ if (isDaemonRunning())
1383
+ signalDaemonReload();
1384
+ if (options.json) {
1385
+ writeJson({
1386
+ ok: true,
1387
+ projectRoot: root,
1388
+ newlyEnabled: newly,
1389
+ synced: sync.synced,
1390
+ skipped: sync.skipped,
1391
+ removed: sync.removed,
1392
+ errors: sync.errors,
1393
+ });
1394
+ return;
1395
+ }
1396
+ console.log(chalk.green(newly
1397
+ ? `Enabled project routines for ${displayProjectPath(root)}`
1398
+ : `Project routines already enabled for ${displayProjectPath(root)}`));
1399
+ if (sync.synced.length > 0) {
1400
+ console.log(chalk.gray(` Synced: ${sync.synced.join(', ')}`));
1401
+ }
1402
+ for (const s of sync.skipped) {
1403
+ console.log(chalk.yellow(` Skipped ${s.name}: ${s.reason}`));
1404
+ }
1405
+ for (const e of sync.errors) {
1406
+ console.log(chalk.red(` Error ${e.name}: ${e.error}`));
1407
+ }
1408
+ console.log(chalk.gray('Daemon will fire these after reload. Re-sync later with: agents routines sync'));
1409
+ });
1410
+ routinesCmd
1411
+ .command('disable-project [path]')
1412
+ .description('Remove a project from the project-routines allowlist. Use --remove-synced to also delete the user-layer copies.')
1413
+ .option('--remove-synced', 'Delete user-layer routines that were materialised from this project')
1414
+ .option('--json', 'Emit machine-readable JSON')
1415
+ .action(async (projectPath, options) => {
1416
+ const root = projectPath
1417
+ ? path.resolve(projectPath)
1418
+ : resolveProjectRoot(process.cwd());
1419
+ if (!root) {
1420
+ console.error(chalk.red('No project .agents/ directory found from the current directory.'));
1421
+ process.exit(1);
1422
+ }
1423
+ const result = disableProjectRoutines(root, { removeSynced: options.removeSynced });
1424
+ if (isDaemonRunning())
1425
+ signalDaemonReload();
1426
+ if (options.json) {
1427
+ writeJson({ ok: true, projectRoot: root, ...result });
1428
+ return;
1429
+ }
1430
+ if (!result.removed) {
1431
+ console.log(chalk.gray(`Project ${displayProjectPath(root)} was not on the allowlist`));
1432
+ }
1433
+ else {
1434
+ console.log(chalk.green(`Disabled project routines for ${displayProjectPath(root)}`));
1435
+ }
1436
+ if (result.deletedJobs.length > 0) {
1437
+ console.log(chalk.gray(` Removed user-layer copies: ${result.deletedJobs.join(', ')}`));
1438
+ }
1439
+ });
1440
+ routinesCmd
1441
+ .command('sync [path]')
1442
+ .description('Refresh user-layer copies of opted-in project routines from their .agents/routines/*.yml sources. With no path, syncs every enabled project. Also runs automatically on daemon reload (SIGHUP).')
1443
+ .option('--json', 'Emit machine-readable JSON')
1444
+ .action(async (projectPath, options) => {
1445
+ if (projectPath) {
1446
+ const root = path.resolve(projectPath);
1447
+ const isEnabled = listEnabledProjectRoots().some((p) => p === root);
1448
+ if (!isEnabled) {
1449
+ console.error(chalk.red(`Project ${displayProjectPath(root)} is not enabled. Run: agents routines enable-project ${root}`));
1450
+ process.exit(1);
1451
+ }
1452
+ const sync = syncProjectRoutines(root);
1453
+ if (isDaemonRunning())
1454
+ signalDaemonReload();
1455
+ if (options.json) {
1456
+ writeJson({ ok: true, ...sync });
1457
+ return;
1458
+ }
1459
+ printSyncResult(sync);
1460
+ return;
1461
+ }
1462
+ const all = syncAllProjectRoutines();
1463
+ if (isDaemonRunning())
1464
+ signalDaemonReload();
1465
+ if (options.json) {
1466
+ writeJson({ ok: true, ...all });
1467
+ return;
1468
+ }
1469
+ if (all.projects.length === 0 && all.missing.length === 0) {
1470
+ console.log(chalk.gray('No project roots on the routines allowlist.'));
1471
+ console.log(chalk.gray(' Enable one with: agents routines enable-project'));
1472
+ return;
1473
+ }
1474
+ for (const p of all.projects)
1475
+ printSyncResult(p);
1476
+ for (const m of all.missing) {
1477
+ console.log(chalk.yellow(`Missing project root (still on allowlist): ${displayProjectPath(m)}`));
1478
+ }
1479
+ });
1480
+ routinesCmd
1481
+ .command('projects')
1482
+ .description('List project roots opted into daemon-fired project routines')
1483
+ .option('--json', 'Emit machine-readable JSON')
1484
+ .action((options) => {
1485
+ const roots = listEnabledProjectRoots();
1486
+ if (options.json) {
1487
+ writeJson(roots.map((r) => ({
1488
+ path: r,
1489
+ display: displayProjectPath(r),
1490
+ routines: listProjectRoutineFiles(r).map((f) => f.name),
1491
+ })));
1492
+ return;
1493
+ }
1494
+ if (roots.length === 0) {
1495
+ console.log(chalk.gray('No projects enabled. Use: agents routines enable-project'));
1496
+ // Offer a discovery hint for the current project.
1497
+ const discovered = discoverProjectRoutinesAt(process.cwd());
1498
+ if (discovered) {
1499
+ console.log(chalk.gray(` Found ${discovered.files.length} routine(s) in ${displayProjectPath(discovered.projectRoot)} — enable with: agents routines enable-project`));
1500
+ }
1501
+ return;
1502
+ }
1503
+ console.log(chalk.bold('Enabled project routines\n'));
1504
+ for (const r of roots) {
1505
+ const files = listProjectRoutineFiles(r);
1506
+ console.log(` ${chalk.cyan(displayProjectPath(r))} ${chalk.gray(`(${files.length} routine${files.length === 1 ? '' : 's'})`)}`);
1507
+ for (const f of files) {
1508
+ console.log(chalk.gray(` - ${f.name}`));
1509
+ }
1510
+ }
1511
+ console.log();
1512
+ });
1253
1513
  routinesCmd
1254
1514
  .command('scheduler-logs')
1255
1515
  .description('Read scheduler log output (for debugging why a routine did not fire). Use --follow to stream.')