@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -21,6 +21,7 @@ import { truncate, termLink } from '../lib/format.js';
21
21
  import * as yaml from 'yaml';
22
22
  import { AGENTS, getCliState, resolveAgentName } from '../lib/agents.js';
23
23
  import { supports } from '../lib/capabilities.js';
24
+ import { resolveConfiguredModel } from '../lib/models.js';
24
25
  import { resolveSingleAgentTarget, AgentSpecError } from '../lib/agent-spec/index.js';
25
26
  import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../lib/state.js';
26
27
  import { getVersionHomePath } from '../lib/versions.js';
@@ -585,8 +586,10 @@ async function renderSummary(agent, version, versionHome, options) {
585
586
  console.log(JSON.stringify(json, null, 2));
586
587
  return;
587
588
  }
588
- // Plain text
589
- const head = `${chalk.bold(agent)} ${chalk.gray('@')} ${chalk.cyan(version)}${isDefault ? ' ' + chalk.green('[default]') : ''}`;
589
+ // Plain text — model sits right beside the version, same priority (no label).
590
+ const configuredModel = resolveConfiguredModel(agent, version)?.model;
591
+ const modelPart = configuredModel ? ' ' + chalk.yellow(configuredModel) : '';
592
+ const head = `${chalk.bold(agent)} ${chalk.gray('@')} ${chalk.cyan(version)}${modelPart}${isDefault ? ' ' + chalk.green('[default]') : ''}`;
590
593
  console.log('\n' + head + '\n');
591
594
  const rows = [
592
595
  ['install', versionHome],
@@ -15,5 +15,25 @@ import { type Preset } from '../lib/profiles-presets.js';
15
15
  * without mocking @inquirer/prompts.
16
16
  */
17
17
  export declare function buildProfileFromCollection(name: string, preset: Preset, collected: Record<string, string>, version?: string): Profile;
18
+ /** Options accepted by {@link addProfile} — shared by `agents profiles add` and `agents harness add`. */
19
+ export interface AddProfileOptions {
20
+ preset?: string;
21
+ host?: string;
22
+ model?: string;
23
+ baseUrl?: string;
24
+ authProvider?: string;
25
+ version?: string;
26
+ keyStdin?: boolean;
27
+ force?: boolean;
28
+ }
29
+ /**
30
+ * Create a profile ("custom harness"). Two paths:
31
+ * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
32
+ * (no preset needed). This is what makes a model like Muse Spark a named,
33
+ * runnable harness.
34
+ * - otherwise: apply a built-in preset (existing behavior).
35
+ * `label` only tunes the success wording (Profile vs Harness). Throws on error.
36
+ */
37
+ export declare function addProfile(name: string, opts: AddProfileOptions, label?: 'Profile' | 'Harness'): Promise<void>;
18
38
  /** Register the `agents profiles` command tree. */
19
39
  export declare function registerProfilesCommands(program: Command): void;
@@ -9,13 +9,13 @@
9
9
  import chalk from 'chalk';
10
10
  import { readStdinSync } from '../lib/format.js';
11
11
  import { spawn } from 'child_process';
12
- import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
12
+ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists, profileFromPreset, profileFromHostModel, baseUrlEnvKeyForHost, authEnvKeyForHost, validateProfileName, getPresetForProfile, } from '../lib/profiles.js';
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
16
  import { getAgentsInvocation } from '../lib/daemon.js';
17
17
  import { getActiveResourceProfileName, getResourceProfilePreset, listResourceProfileNames, setActiveResourceProfile, upsertResourceProfilePreset, validateResourceProfileName, } from '../lib/resource-profiles.js';
18
- import { AGENTS } from '../lib/agents.js';
18
+ import { AGENTS, ALL_AGENT_IDS } from '../lib/agents.js';
19
19
  import { listInstalledVersions, syncResourcesToVersion } from '../lib/versions.js';
20
20
  /**
21
21
  * Pure helper: builds a Profile from collected wizard inputs. Extracted so the
@@ -31,6 +31,7 @@ export function buildProfileFromCollection(name, preset, collected, version) {
31
31
  envVar: preset.authEnvVar,
32
32
  keychainItem: keychainItemName(preset.provider),
33
33
  },
34
+ authOptional: preset.authOptional,
34
35
  description: preset.description,
35
36
  preset: preset.name,
36
37
  provider: preset.provider,
@@ -65,6 +66,66 @@ async function ensureProviderToken(provider, signupUrl, fromStdin) {
65
66
  setKeychainToken(item, token);
66
67
  console.log(chalk.green(`Stored in keychain: ${item}`));
67
68
  }
69
+ /**
70
+ * Create a profile ("custom harness"). Two paths:
71
+ * - `--host <agent> --model <id>`: one-shot custom harness from a host + model
72
+ * (no preset needed). This is what makes a model like Muse Spark a named,
73
+ * runnable harness.
74
+ * - otherwise: apply a built-in preset (existing behavior).
75
+ * `label` only tunes the success wording (Profile vs Harness). Throws on error.
76
+ */
77
+ export async function addProfile(name, opts, label = 'Profile') {
78
+ validateProfileName(name);
79
+ if (profileExists(name) && !opts.force) {
80
+ throw new Error(`${label} '${name}' already exists. Use --force to overwrite.`);
81
+ }
82
+ // One-shot host + model → custom harness, no preset required.
83
+ if (opts.host || opts.model) {
84
+ if (!opts.host || !opts.model) {
85
+ throw new Error('Both --host <agent> and --model <id> are required to build a harness from a host + model.');
86
+ }
87
+ if (!ALL_AGENT_IDS.includes(opts.host)) {
88
+ throw new Error(`Unknown host '${opts.host}'. Valid hosts: ${ALL_AGENT_IDS.join(', ')}`);
89
+ }
90
+ const host = opts.host;
91
+ if (opts.baseUrl && !baseUrlEnvKeyForHost(host)) {
92
+ console.error(chalk.yellow(`Note: --base-url has no known env var for host '${host}'; ignoring it.`));
93
+ }
94
+ let authEnvVar;
95
+ if (opts.authProvider) {
96
+ const key = authEnvKeyForHost(host);
97
+ if (!key) {
98
+ throw new Error(`--auth-provider is set but host '${host}' has no known auth env var. Use a preset or a hand-written profile YAML.`);
99
+ }
100
+ authEnvVar = key;
101
+ await ensureProviderToken(opts.authProvider, undefined, opts.keyStdin);
102
+ }
103
+ const profile = profileFromHostModel(name, host, opts.model, {
104
+ version: opts.version,
105
+ baseUrl: opts.baseUrl,
106
+ provider: opts.authProvider,
107
+ authEnvVar,
108
+ });
109
+ writeProfile(profile);
110
+ console.log(chalk.green(`${label} '${name}' added — ${host} + ${opts.model}.`));
111
+ console.log(chalk.gray(`Try: agents run ${name} "hello"`));
112
+ return;
113
+ }
114
+ // Preset path.
115
+ const presetName = opts.preset || name;
116
+ const preset = getPreset(presetName);
117
+ if (!preset) {
118
+ throw new Error(`No preset '${presetName}'.\nAvailable presets: ${listPresets().map((p) => p.name).join(', ')}\n` +
119
+ 'Or build a custom harness: --host <agent> --model <id>.');
120
+ }
121
+ if (!preset.authOptional) {
122
+ await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
123
+ }
124
+ const profile = profileFromPreset(name, preset, opts.version);
125
+ writeProfile(profile);
126
+ console.log(chalk.green(`${label} '${name}' added.`));
127
+ console.log(chalk.gray(`Try: agents run ${name} "hello"`));
128
+ }
68
129
  /** Format a single profile as a table row for the `profiles list` output. */
69
130
  function renderProfileRow(p) {
70
131
  const host = p.host.version ? `${p.host.agent}@${p.host.version}` : p.host.agent;
@@ -462,27 +523,13 @@ Examples:
462
523
  .option('--version <version>', 'Pin the host CLI version (e.g., 2.1.113)')
463
524
  .option('--key-stdin', 'Read API key from stdin instead of prompting (for scripts/CI)')
464
525
  .option('--force', 'Overwrite an existing profile with the same name')
526
+ .addHelpText('after', '\nTo build a custom harness from a host CLI + model in one shot, use `agents harness add`.\n')
465
527
  .action(async (name, opts) => {
528
+ // Preset-only surface here — `--host` on `profiles` is reserved for remote
529
+ // device routing (see lib/hosts/passthrough.ts). The host+model one-shot
530
+ // lives on `agents harness add`, which owns its own `--host`.
466
531
  try {
467
- if (profileExists(name) && !opts.force) {
468
- console.error(chalk.red(`Profile '${name}' already exists. Use --force to overwrite.`));
469
- process.exit(1);
470
- }
471
- const presetName = opts.preset || name;
472
- const preset = getPreset(presetName);
473
- if (!preset) {
474
- console.error(chalk.red(`No preset '${presetName}'.`));
475
- console.error(chalk.gray('Available presets: ' + listPresets().map((p) => p.name).join(', ')));
476
- console.error(chalk.gray('Or pass --preset <name> to pick explicitly.'));
477
- process.exit(1);
478
- }
479
- if (!preset.authOptional) {
480
- await ensureProviderToken(preset.provider, preset.signupUrl, opts.keyStdin);
481
- }
482
- const profile = profileFromPreset(name, preset, opts.version);
483
- writeProfile(profile);
484
- console.log(chalk.green(`Profile '${name}' added.`));
485
- console.log(chalk.gray(`Try: agents run ${name} "hello"`));
532
+ await addProfile(name, { preset: opts.preset, version: opts.version, keyStdin: opts.keyStdin, force: opts.force }, 'Profile');
486
533
  }
487
534
  catch (err) {
488
535
  console.error(chalk.red(err.message));
@@ -812,6 +812,7 @@ export function registerRepoCommands(program) {
812
812
  console.log(chalk.gray('No repos to pull.'));
813
813
  return;
814
814
  }
815
+ let anyPulled = false;
815
816
  for (const t of targets) {
816
817
  if (!fs.existsSync(t.dir) || !isGitRepo(t.dir)) {
817
818
  // A plain (never-cloned) user repo — setup makes ~/.agents a bare dir and
@@ -851,11 +852,24 @@ export function registerRepoCommands(program) {
851
852
  const result = await pullRepo(t.dir);
852
853
  if (result.success) {
853
854
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.commit}`);
855
+ anyPulled = true;
854
856
  }
855
857
  else {
856
858
  spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
857
859
  }
858
860
  }
861
+ // RUSH-1980: a pull rewrites the routine YAML on disk, but the daemon's
862
+ // scheduler froze its JobConfigs (device pins included) at load. Without a
863
+ // reload it keeps firing the pre-pull pins — a routine re-pinned to another
864
+ // host still fires here, double-firing across the fleet. SIGHUP the daemon
865
+ // so scheduler.reloadAll() re-reads the synced YAML and device pins refresh.
866
+ // No-op when the daemon isn't running (or on Windows, which has no SIGHUP).
867
+ if (anyPulled) {
868
+ const { isDaemonRunning, signalDaemonReload } = await import('../lib/daemon.js');
869
+ if (isDaemonRunning() && signalDaemonReload()) {
870
+ console.log(chalk.gray('Reloaded the routines daemon (device pins refreshed).'));
871
+ }
872
+ }
859
873
  });
860
874
  repoCmd
861
875
  .command('push [alias]')
@@ -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
 
@@ -323,6 +350,10 @@ export function registerRoutinesCommands(program) {
323
350
  timezone: job.timezone ?? null,
324
351
  devices: job.devices ?? [],
325
352
  host: job.host ?? null,
353
+ hostStrategy: resolveHostStrategy(job),
354
+ source: job.source ?? null,
355
+ sourceRepo: job.source?.repo ?? job.repo ?? null,
356
+ sourceBranch: job.source?.branch ?? null,
326
357
  runsHere: jobRunsOnThisDevice(job),
327
358
  enabled: job.enabled,
328
359
  overdue: overdueSet.has(job.name),
@@ -368,7 +399,12 @@ export function registerRoutinesCommands(program) {
368
399
  }
369
400
  const latestRun = getLatestRun(job.name);
370
401
  const lastStatus = latestRun?.status || '-';
371
- const repoInfo = formatRepoLink(job.repo);
402
+ // Prefer project-source repo (with optional @branch) over bare job.repo.
403
+ const sourceRepo = job.source?.repo ?? job.repo;
404
+ const sourceLabel = sourceRepo
405
+ ? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
406
+ : null;
407
+ const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
372
408
  const repoCell = link(repoInfo.display, repoInfo.href);
373
409
  // Pad based on the display string, not the raw cell (which may include escape codes).
374
410
  const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
@@ -376,8 +412,16 @@ export function registerRoutinesCommands(program) {
376
412
  // chalk adds escape codes; pad the raw word and let chalk wrap it.
377
413
  const enabledWord = job.enabled ? 'yes' : 'no';
378
414
  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}` : '']
415
+ // Placement rides in the Devices cell: eligibility →execution strategy.
416
+ const strategy = resolveHostStrategy(job);
417
+ const placementTag = strategy === 'local'
418
+ ? (job.host ? `→${job.host}` : '')
419
+ : strategy === 'host'
420
+ ? `→${job.host ?? '?'}`
421
+ : strategy === 'fleet'
422
+ ? '→fleet'
423
+ : '→cloud';
424
+ const deviceFull = [job.devices?.join(',') ?? '', placementTag]
381
425
  .filter(Boolean)
382
426
  .join(' ');
383
427
  const deviceWord = deviceFull.length === 0
@@ -423,7 +467,8 @@ export function registerRoutinesCommands(program) {
423
467
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
424
468
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
425
469
  .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.')
470
+ .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.')
471
+ .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
472
  .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
428
473
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
429
474
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
@@ -490,12 +535,29 @@ export function registerRoutinesCommands(program) {
490
535
  if (options.devices !== undefined) {
491
536
  devices = await parseAndValidateDevices(options.devices);
492
537
  }
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) {
538
+ let hostStrategy;
539
+ try {
540
+ hostStrategy = parseHostStrategy(options.placement) ?? undefined;
541
+ }
542
+ catch (err) {
543
+ console.error(chalk.red(err.message));
544
+ process.exit(1);
545
+ }
546
+ // --run-on implies host strategy when the user didn't pick one.
547
+ if (options.runOn && !hostStrategy)
548
+ hostStrategy = 'host';
549
+ if (hostStrategy === 'host' && !options.runOn) {
550
+ console.error(chalk.red('--placement host requires --run-on <name>'));
551
+ process.exit(1);
552
+ }
553
+ // Off-box placement without a --devices pin would fire on EVERY daemon
554
+ // in the fleet, each dispatching once — duplicate runs (RUSH-1980).
555
+ // Pin to this machine unless the user chose an explicit eligibility set.
556
+ const strategyForPin = hostStrategy
557
+ ?? (options.runOn ? 'host' : 'local');
558
+ if (placementRequiresFiringPin(strategyForPin) && !devices) {
497
559
  devices = [machineId()];
498
- console.error(chalk.gray(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
560
+ console.error(chalk.gray(`--placement ${strategyForPin} with no --devices: pinned firing to this machine (${devices[0]}).`));
499
561
  }
500
562
  const config = {
501
563
  name: nameOrPath,
@@ -512,6 +574,7 @@ export function registerRoutinesCommands(program) {
512
574
  timezone: options.timezone,
513
575
  ...(devices ? { devices } : {}),
514
576
  ...(options.runOn ? { host: options.runOn } : {}),
577
+ ...(hostStrategy ? { hostStrategy } : {}),
515
578
  ...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
516
579
  ...(runOnce ? { runOnce: true } : {}),
517
580
  ...(options.endAt ? { endAt: options.endAt } : {}),
@@ -586,11 +649,12 @@ export function registerRoutinesCommands(program) {
586
649
  enabled: true,
587
650
  ...parsed,
588
651
  };
589
- // Same duplicate-fire guard as the --run-on flag: a host-placed routine
652
+ // Same duplicate-fire guard as --placement/--run-on: off-box placement
590
653
  // with no eligibility pin would fire from every daemon in the fleet.
591
- if (config.host && (!config.devices || config.devices.length === 0)) {
654
+ const fileStrategy = resolveHostStrategy(config);
655
+ if (placementRequiresFiringPin(fileStrategy) && (!config.devices || config.devices.length === 0)) {
592
656
  config.devices = [machineId()];
593
- console.error(chalk.gray(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
657
+ console.error(chalk.gray(`${fileStrategy} placement with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
594
658
  }
595
659
  writeJob(config);
596
660
  if (options.json) {
@@ -783,18 +847,26 @@ export function registerRoutinesCommands(program) {
783
847
  try {
784
848
  const result = await executeJob(job);
785
849
  const logPath = `${getRunDir(name, result.meta.runId)}/stdout.log`;
850
+ const succeeded = result.meta.status === 'completed';
786
851
  if (options.json) {
787
852
  writeJson({
788
- ok: true,
853
+ ok: succeeded,
789
854
  job: name,
790
855
  logDir: getRunDir(name, result.meta.runId),
791
856
  ...runMetaJson(result.meta),
792
857
  logPath,
793
858
  reportPath: result.reportPath ?? null,
794
859
  });
860
+ // A failed run must exit non-zero so cron wrappers, `&&` chains, and
861
+ // `--json` consumers actually see the failure (a logged-out agent used
862
+ // to exit 0 with ok:true, hiding the whole auth-failure epidemic). Set
863
+ // exitCode rather than process.exit() so the JSON payload is fully
864
+ // flushed to a pipe before the process ends.
865
+ if (!succeeded)
866
+ process.exitCode = 1;
795
867
  return;
796
868
  }
797
- if (result.meta.status === 'completed') {
869
+ if (succeeded) {
798
870
  spinner.succeed(`Job completed (exit code: ${result.meta.exitCode})`);
799
871
  }
800
872
  else if (result.meta.status === 'timeout') {
@@ -805,10 +877,15 @@ export function registerRoutinesCommands(program) {
805
877
  }
806
878
  console.log(chalk.gray(` Run: ${result.meta.runId}`));
807
879
  console.log(chalk.gray(` Log: ${logPath}`));
880
+ if (result.meta.errorMessage) {
881
+ console.log(chalk.gray(` Reason: ${result.meta.errorMessage}`));
882
+ }
808
883
  if (result.reportPath) {
809
884
  console.log(chalk.bold('\nReport:\n'));
810
885
  console.log(fs.readFileSync(result.reportPath, 'utf-8'));
811
886
  }
887
+ if (!succeeded)
888
+ process.exitCode = 1;
812
889
  }
813
890
  catch (err) {
814
891
  if (options.json) {
@@ -1107,6 +1184,12 @@ export function registerRoutinesCommands(program) {
1107
1184
  process.exit(1);
1108
1185
  }
1109
1186
  if (options.clear) {
1187
+ const strategy = resolveHostStrategy(job);
1188
+ if (placementRequiresFiringPin(strategy)) {
1189
+ console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1190
+ console.error(chalk.gray(`Keep a single-machine pin (e.g. --set ${machineId()}) or switch to --placement local.`));
1191
+ process.exit(1);
1192
+ }
1110
1193
  job.devices = undefined;
1111
1194
  writeJob(job);
1112
1195
  console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
@@ -1117,6 +1200,12 @@ export function registerRoutinesCommands(program) {
1117
1200
  if (hasSet) {
1118
1201
  const devices = await parseAndValidateDevices(options.set);
1119
1202
  job.devices = devices;
1203
+ // Re-validate so host/fleet/cloud can't be left with an empty set via --set ""
1204
+ const errs = validateJob(job);
1205
+ if (errs.length > 0) {
1206
+ console.error(chalk.red(errs.join('\n')));
1207
+ process.exit(1);
1208
+ }
1120
1209
  writeJob(job);
1121
1210
  console.log(chalk.green(`Devices for '${name}' set to: ${devices.join(', ')}`));
1122
1211
  if (isDaemonRunning())
@@ -1145,6 +1234,11 @@ export function registerRoutinesCommands(program) {
1145
1234
  })),
1146
1235
  });
1147
1236
  if (selected.length === 0) {
1237
+ const strategy = resolveHostStrategy(job);
1238
+ if (placementRequiresFiringPin(strategy)) {
1239
+ console.error(chalk.red(`Cannot clear devices for hostStrategy: ${strategy} — without a pin every fleet daemon would dispatch once.`));
1240
+ return;
1241
+ }
1148
1242
  job.devices = undefined;
1149
1243
  writeJob(job);
1150
1244
  console.log(chalk.green(`Devices cleared for '${name}' — runs on all devices`));
@@ -1237,6 +1331,183 @@ export function registerRoutinesCommands(program) {
1237
1331
  console.log(chalk.gray('\n Start the scheduler to begin firing routines: agents routines start'));
1238
1332
  }
1239
1333
  });
1334
+ routinesCmd
1335
+ .command('enable-project [path]')
1336
+ .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.')
1337
+ .option('--yes', 'Skip the interactive confirmation prompt')
1338
+ .option('--json', 'Emit machine-readable JSON')
1339
+ .action(async (projectPath, options) => {
1340
+ const root = projectPath
1341
+ ? path.resolve(projectPath)
1342
+ : resolveProjectRoot(process.cwd());
1343
+ if (!root) {
1344
+ console.error(chalk.red('No project .agents/ directory found from the current directory.'));
1345
+ console.error(chalk.gray('Run from inside a project, or pass the project path: agents routines enable-project /path/to/repo'));
1346
+ process.exit(1);
1347
+ }
1348
+ const files = listProjectRoutineFiles(root);
1349
+ if (files.length === 0) {
1350
+ console.error(chalk.red(`No routines found under ${path.join(root, '.agents', 'routines')}`));
1351
+ process.exit(1);
1352
+ }
1353
+ if (!options.yes) {
1354
+ if (!isInteractiveTerminal()) {
1355
+ console.error(chalk.red('Refusing to enable project routines non-interactively without --yes.'));
1356
+ console.error(chalk.gray(`Found ${files.length} routine(s) in ${displayProjectPath(root)}. Re-run with --yes to confirm.`));
1357
+ process.exit(1);
1358
+ }
1359
+ try {
1360
+ const { confirm } = await import('@inquirer/prompts');
1361
+ const ok = await confirm({
1362
+ message: `Enable daemon firing for ${files.length} project routine(s) in ${displayProjectPath(root)}?`,
1363
+ default: false,
1364
+ });
1365
+ if (!ok) {
1366
+ console.log(chalk.gray('Cancelled'));
1367
+ return;
1368
+ }
1369
+ }
1370
+ catch (err) {
1371
+ if (isPromptCancelled(err)) {
1372
+ console.log(chalk.gray('Cancelled'));
1373
+ return;
1374
+ }
1375
+ throw err;
1376
+ }
1377
+ }
1378
+ const newly = enableProjectRoutines(root);
1379
+ const sync = syncProjectRoutines(root);
1380
+ if (isDaemonRunning())
1381
+ signalDaemonReload();
1382
+ if (options.json) {
1383
+ writeJson({
1384
+ ok: true,
1385
+ projectRoot: root,
1386
+ newlyEnabled: newly,
1387
+ synced: sync.synced,
1388
+ skipped: sync.skipped,
1389
+ removed: sync.removed,
1390
+ errors: sync.errors,
1391
+ });
1392
+ return;
1393
+ }
1394
+ console.log(chalk.green(newly
1395
+ ? `Enabled project routines for ${displayProjectPath(root)}`
1396
+ : `Project routines already enabled for ${displayProjectPath(root)}`));
1397
+ if (sync.synced.length > 0) {
1398
+ console.log(chalk.gray(` Synced: ${sync.synced.join(', ')}`));
1399
+ }
1400
+ for (const s of sync.skipped) {
1401
+ console.log(chalk.yellow(` Skipped ${s.name}: ${s.reason}`));
1402
+ }
1403
+ for (const e of sync.errors) {
1404
+ console.log(chalk.red(` Error ${e.name}: ${e.error}`));
1405
+ }
1406
+ console.log(chalk.gray('Daemon will fire these after reload. Re-sync later with: agents routines sync'));
1407
+ });
1408
+ routinesCmd
1409
+ .command('disable-project [path]')
1410
+ .description('Remove a project from the project-routines allowlist. Use --remove-synced to also delete the user-layer copies.')
1411
+ .option('--remove-synced', 'Delete user-layer routines that were materialised from this project')
1412
+ .option('--json', 'Emit machine-readable JSON')
1413
+ .action(async (projectPath, options) => {
1414
+ const root = projectPath
1415
+ ? path.resolve(projectPath)
1416
+ : resolveProjectRoot(process.cwd());
1417
+ if (!root) {
1418
+ console.error(chalk.red('No project .agents/ directory found from the current directory.'));
1419
+ process.exit(1);
1420
+ }
1421
+ const result = disableProjectRoutines(root, { removeSynced: options.removeSynced });
1422
+ if (isDaemonRunning())
1423
+ signalDaemonReload();
1424
+ if (options.json) {
1425
+ writeJson({ ok: true, projectRoot: root, ...result });
1426
+ return;
1427
+ }
1428
+ if (!result.removed) {
1429
+ console.log(chalk.gray(`Project ${displayProjectPath(root)} was not on the allowlist`));
1430
+ }
1431
+ else {
1432
+ console.log(chalk.green(`Disabled project routines for ${displayProjectPath(root)}`));
1433
+ }
1434
+ if (result.deletedJobs.length > 0) {
1435
+ console.log(chalk.gray(` Removed user-layer copies: ${result.deletedJobs.join(', ')}`));
1436
+ }
1437
+ });
1438
+ routinesCmd
1439
+ .command('sync [path]')
1440
+ .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).')
1441
+ .option('--json', 'Emit machine-readable JSON')
1442
+ .action(async (projectPath, options) => {
1443
+ if (projectPath) {
1444
+ const root = path.resolve(projectPath);
1445
+ const isEnabled = listEnabledProjectRoots().some((p) => p === root);
1446
+ if (!isEnabled) {
1447
+ console.error(chalk.red(`Project ${displayProjectPath(root)} is not enabled. Run: agents routines enable-project ${root}`));
1448
+ process.exit(1);
1449
+ }
1450
+ const sync = syncProjectRoutines(root);
1451
+ if (isDaemonRunning())
1452
+ signalDaemonReload();
1453
+ if (options.json) {
1454
+ writeJson({ ok: true, ...sync });
1455
+ return;
1456
+ }
1457
+ printSyncResult(sync);
1458
+ return;
1459
+ }
1460
+ const all = syncAllProjectRoutines();
1461
+ if (isDaemonRunning())
1462
+ signalDaemonReload();
1463
+ if (options.json) {
1464
+ writeJson({ ok: true, ...all });
1465
+ return;
1466
+ }
1467
+ if (all.projects.length === 0 && all.missing.length === 0) {
1468
+ console.log(chalk.gray('No project roots on the routines allowlist.'));
1469
+ console.log(chalk.gray(' Enable one with: agents routines enable-project'));
1470
+ return;
1471
+ }
1472
+ for (const p of all.projects)
1473
+ printSyncResult(p);
1474
+ for (const m of all.missing) {
1475
+ console.log(chalk.yellow(`Missing project root (still on allowlist): ${displayProjectPath(m)}`));
1476
+ }
1477
+ });
1478
+ routinesCmd
1479
+ .command('projects')
1480
+ .description('List project roots opted into daemon-fired project routines')
1481
+ .option('--json', 'Emit machine-readable JSON')
1482
+ .action((options) => {
1483
+ const roots = listEnabledProjectRoots();
1484
+ if (options.json) {
1485
+ writeJson(roots.map((r) => ({
1486
+ path: r,
1487
+ display: displayProjectPath(r),
1488
+ routines: listProjectRoutineFiles(r).map((f) => f.name),
1489
+ })));
1490
+ return;
1491
+ }
1492
+ if (roots.length === 0) {
1493
+ console.log(chalk.gray('No projects enabled. Use: agents routines enable-project'));
1494
+ // Offer a discovery hint for the current project.
1495
+ const discovered = discoverProjectRoutinesAt(process.cwd());
1496
+ if (discovered) {
1497
+ console.log(chalk.gray(` Found ${discovered.files.length} routine(s) in ${displayProjectPath(discovered.projectRoot)} — enable with: agents routines enable-project`));
1498
+ }
1499
+ return;
1500
+ }
1501
+ console.log(chalk.bold('Enabled project routines\n'));
1502
+ for (const r of roots) {
1503
+ const files = listProjectRoutineFiles(r);
1504
+ console.log(` ${chalk.cyan(displayProjectPath(r))} ${chalk.gray(`(${files.length} routine${files.length === 1 ? '' : 's'})`)}`);
1505
+ for (const f of files) {
1506
+ console.log(chalk.gray(` - ${f.name}`));
1507
+ }
1508
+ }
1509
+ console.log();
1510
+ });
1240
1511
  routinesCmd
1241
1512
  .command('scheduler-logs')
1242
1513
  .description('Read scheduler log output (for debugging why a routine did not fire). Use --follow to stream.')