@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Owner phone notification on routine FAILURE (RUSH-2288).
3
+ *
4
+ * The desktop lifecycle notifications (routine-notify.ts) never leave the
5
+ * machine, so a failed scheduled routine on a headless fleet box was invisible
6
+ * until someone happened to look. The per-routine prompt pattern (a prompt that
7
+ * ends by shelling `agents notify`) does not close the gap either: when the
8
+ * routine's OWN agent fails to spawn (`auth_failed`) that prompt never runs, so
9
+ * the one failure most worth surfacing is the one that goes unheard.
10
+ *
11
+ * So the DAEMON delivers on failure, from the SAME owner channel stack `agents
12
+ * notify` uses (humans.yaml owner channels, or the legacy `notify.owner`),
13
+ * calling the channel providers IN-PROCESS — no shelling out to `ssh mac-mini
14
+ * agents notify`. When the primary owner channel cannot deliver from this box it
15
+ * walks the remaining configured owner channels as fallbacks (OpenClaw, a second
16
+ * channel, …). Telegram is never used.
17
+ *
18
+ * Only failures notify. A green routine of any kind stays silent — this is an
19
+ * ADDITIONAL failures-only lane, and the desktop thresholds (which still ping a
20
+ * green agent/workflow finish on the local screen) are unchanged. Delivery is
21
+ * deduped per job+runId so a run is announced to the owner at most once.
22
+ *
23
+ * The message BUILDERS (`routineFinishOwnerText` / `routineStartFailedOwnerText`)
24
+ * are pure and unit-tested; `notifyOwnerRoutineFinish` /
25
+ * `notifyOwnerRoutineStartFailed` are the daemon glue that reads config and
26
+ * delivers.
27
+ */
28
+ import * as os from 'os';
29
+ import { routineKind } from './routine-notify.js';
30
+ import { readMeta } from './state.js';
31
+ import { getOwnerFromHumans } from './humans.js';
32
+ import { readOwnerDest } from './channels/send.js';
33
+ import { registerBuiltinProviders } from './channels/providers/index.js';
34
+ import { lookupTransport } from './channels/resolve.js';
35
+ /** Transports that are Telegram in any form — never delivered to (owner rule). */
36
+ const TELEGRAM_TRANSPORTS = new Set(['telegram', 'openclaw-telegram']);
37
+ /** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
38
+ function routineLabel(r) {
39
+ const kind = routineKind(r);
40
+ if (kind === 'command')
41
+ return 'command';
42
+ if (kind === 'workflow')
43
+ return `workflow ${r.workflow}`;
44
+ return `agent ${r.agent ?? 'unknown'}`;
45
+ }
46
+ /** The failure reason line for a terminal run: timeout / error message / exit code. */
47
+ function failureReason(meta) {
48
+ if (meta.status === 'timeout')
49
+ return 'Timed out';
50
+ if (meta.errorMessage)
51
+ return meta.errorMessage;
52
+ return `Exited with code ${meta.exitCode ?? '?'}`;
53
+ }
54
+ /**
55
+ * Owner-phone text for a routine FINISH, or null when the run did NOT fail. Only
56
+ * `failed` and `timeout` are failures worth an owner ping — a `completed` run
57
+ * (any kind) is silent, and `running`/`missed` never reach this path from the
58
+ * daemon's finish hook. The text is a short, phone-sized pointer: what failed,
59
+ * why, and on which box (so the owner knows where to look), well under the
60
+ * `user-message-guard` length ceiling.
61
+ */
62
+ export function routineFinishOwnerText(meta, host) {
63
+ if (meta.status !== 'failed' && meta.status !== 'timeout')
64
+ return null;
65
+ return `Routine failed: ${meta.jobName}\n${failureReason(meta)}\n${routineLabel(meta)} · ${host}`;
66
+ }
67
+ /**
68
+ * Owner-phone text for a routine that failed to even START (the daemon's
69
+ * pre-spawn catch — `executeJobDetached` threw before a child existed, e.g.
70
+ * `auth_failed`). Never null: a broken start is always a failure worth the ping,
71
+ * for every routine kind, and it is exactly the case the per-routine `agents
72
+ * notify` prompt can never cover because that prompt never ran.
73
+ */
74
+ export function routineStartFailedOwnerText(config, error, host) {
75
+ return `Routine failed to start: ${config.name}\n${error}\n${routineLabel(config)} · ${host}`;
76
+ }
77
+ /**
78
+ * True when a channel would deliver over Telegram. Checks, in order:
79
+ * 1. the channel id itself (`telegram`);
80
+ * 2. the humans.yaml `transport` field on the channel entry (when provided);
81
+ * 3. `notify.transports[id]` remapping (or the id as the default transport name).
82
+ * Any of those landing on a Telegram-delivering provider is excluded.
83
+ */
84
+ function isTelegramChannel(channelId, meta, humansTransport) {
85
+ if (channelId.trim().toLowerCase() === 'telegram')
86
+ return true;
87
+ if (humansTransport && TELEGRAM_TRANSPORTS.has(humansTransport.trim().toLowerCase())) {
88
+ return true;
89
+ }
90
+ const transport = meta.notify?.transports?.[channelId] ?? channelId;
91
+ return TELEGRAM_TRANSPORTS.has(transport.trim().toLowerCase());
92
+ }
93
+ /**
94
+ * Ordered owner delivery plan for a failure ping: the primary owner destination
95
+ * (the same one `agents notify` resolves) first, then every other configured
96
+ * owner channel as a fallback. Deduped by (channel, to). Telegram channels and
97
+ * intrusive channels (a voice call is too much for a routine failure) are
98
+ * excluded entirely — so an owner whose ONLY channel is Telegram gets an empty
99
+ * plan and no ping, which is the intended "silence beats Telegram" outcome.
100
+ *
101
+ * The same filters apply to the primary and the fallback list — a policy that
102
+ * points normal severity at a voice channel must not auto-call the owner on
103
+ * every routine failure.
104
+ */
105
+ export function ownerFailureDeliveryPlan(meta) {
106
+ const plan = [];
107
+ const seen = new Set();
108
+ const ownerChannels = getOwnerFromHumans()?.channels ?? [];
109
+ const byId = new Map(ownerChannels.map((ch) => [ch.id, ch]));
110
+ const push = (channel, to) => {
111
+ const c = channel?.trim();
112
+ const t = to?.trim();
113
+ if (!c || !t)
114
+ return;
115
+ const entry = byId.get(c);
116
+ if (entry?.intrusive)
117
+ return;
118
+ if (isTelegramChannel(c, meta, entry?.transport))
119
+ return;
120
+ const key = `${c} ${t}`;
121
+ if (seen.has(key))
122
+ return;
123
+ seen.add(key);
124
+ plan.push({ channel: c, to: t });
125
+ };
126
+ // Primary = the destination `agents notify` would resolve (humans policy →
127
+ // first addressable → legacy notify.owner). Same filters as the fallbacks.
128
+ const primary = readOwnerDest(meta);
129
+ if (primary)
130
+ push(primary.channel, primary.to);
131
+ // Fallbacks = the remaining configured owner channels, in declared order.
132
+ for (const ch of ownerChannels) {
133
+ push(ch.id, ch.to);
134
+ }
135
+ return plan;
136
+ }
137
+ /**
138
+ * Deliver `text` to the owner over the failure plan, in-process, trying each
139
+ * channel until one accepts. Uses the non-dying `lookupTransport` (never
140
+ * `resolveTransport`, which `die()`s the process) so a daemon survives a bad
141
+ * channel name. Best-effort by contract: the daemon wraps this in try/catch, but
142
+ * it also never throws for a delivery failure — it returns the attempt log.
143
+ */
144
+ export async function deliverOwnerFailure(text, meta) {
145
+ const plan = ownerFailureDeliveryPlan(meta);
146
+ const attempts = [];
147
+ if (plan.length === 0)
148
+ return { delivered: false, attempts };
149
+ registerBuiltinProviders();
150
+ for (const dest of plan) {
151
+ const { provider, error } = lookupTransport(dest.channel, meta);
152
+ if (!provider) {
153
+ attempts.push({ channel: dest.channel, ok: false, error });
154
+ continue;
155
+ }
156
+ try {
157
+ const result = await provider.send(text, {
158
+ target: dest.to,
159
+ from: 'routines',
160
+ ownerScoped: true,
161
+ });
162
+ attempts.push({ channel: dest.channel, ok: result.ok, error: result.error });
163
+ if (result.ok)
164
+ return { delivered: true, channel: dest.channel, attempts };
165
+ }
166
+ catch (err) {
167
+ attempts.push({ channel: dest.channel, ok: false, error: err.message });
168
+ }
169
+ }
170
+ return { delivered: false, attempts };
171
+ }
172
+ /**
173
+ * Per-process dedup of failure pings, keyed by job+runId (finishes) or the
174
+ * synthetic start-failed key. A run is announced to the owner at most once even
175
+ * if the finish hook fires twice or a later sweep re-finalizes the same record.
176
+ * Bounded so a long-lived daemon never grows it without limit.
177
+ */
178
+ const notifiedFailures = new Set();
179
+ const MAX_DEDUP_KEYS = 1000;
180
+ /**
181
+ * Claim a dedup key for an in-flight delivery. Returns false when this
182
+ * job+runId was already delivered (or is already in flight). Callers MUST
183
+ * {@link releaseFailureKey} when delivery fails so a later tick can retry —
184
+ * claiming before send without a release would suppress permanent failures
185
+ * forever (RUSH-2288 review).
186
+ */
187
+ function claimFailureKey(key) {
188
+ if (notifiedFailures.has(key))
189
+ return false;
190
+ if (notifiedFailures.size >= MAX_DEDUP_KEYS)
191
+ notifiedFailures.clear();
192
+ notifiedFailures.add(key);
193
+ return true;
194
+ }
195
+ /** Drop a key so a failed delivery can be retried on the next finish hook. */
196
+ function releaseFailureKey(key) {
197
+ notifiedFailures.delete(key);
198
+ }
199
+ /** Test-only: reset the dedup set so cases don't leak state into each other. */
200
+ export function __resetOwnerFailureDedup() {
201
+ notifiedFailures.clear();
202
+ }
203
+ /** A green run / deduped skip attempted no channel. */
204
+ const NO_DELIVERY = { delivered: false, attempts: [] };
205
+ /**
206
+ * Daemon glue: on a routine FINISH, ping the owner IFF the run failed. Green
207
+ * runs return early (no ping). Deduped per job+runId — claimed for the
208
+ * in-flight window so two finish hooks don't double-text, released when no
209
+ * channel accepted so a later sweep can retry. Best-effort.
210
+ */
211
+ export async function notifyOwnerRoutineFinish(meta) {
212
+ const text = routineFinishOwnerText(meta, os.hostname());
213
+ if (!text)
214
+ return NO_DELIVERY; // green / non-terminal → silent
215
+ const key = `${meta.jobName} ${meta.runId}`;
216
+ if (!claimFailureKey(key))
217
+ return NO_DELIVERY;
218
+ const result = await deliverOwnerFailure(text, readMeta());
219
+ if (!result.delivered)
220
+ releaseFailureKey(key);
221
+ return result;
222
+ }
223
+ /**
224
+ * Daemon glue: on a pre-spawn failure (no run record, so no finish hook and no
225
+ * runId), ping the owner. Not deduped — a pre-spawn failure is one scheduled
226
+ * fire on the cron cadence (never a per-second storm), and each such fire is a
227
+ * distinct failure the owner should hear about. Best-effort.
228
+ */
229
+ export async function notifyOwnerRoutineStartFailed(config, error) {
230
+ const text = routineStartFailedOwnerText(config, error, os.hostname());
231
+ return deliverOwnerFailure(text, readMeta());
232
+ }
@@ -313,6 +313,16 @@ export interface RunMeta {
313
313
  jobName: string;
314
314
  runId: string;
315
315
  agent?: AgentId;
316
+ /**
317
+ * Resolved agent version this run launched under. Re-pointed to each failover
318
+ * attempt's version as the single-shot chain advances (runner.ts), so it names
319
+ * the version that actually ran and wrote the transcript. Recorded so
320
+ * `archiveRoutineTranscripts` can find the transcript in the per-version home a
321
+ * config-dir-relocating agent (claude/codex) writes to, and so account
322
+ * attribution can name the home that ran (RUSH-2271). Unset for
323
+ * command/self-updating runs.
324
+ */
325
+ version?: string;
316
326
  workflow?: string;
317
327
  /** The shell command that ran, for command-mode routines (no agent). */
318
328
  command?: string;
@@ -14,7 +14,7 @@ import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getP
14
14
  import { safeJoin, isSafeSegmentName } from './paths.js';
15
15
  import { isSafeProjectName } from './projects.js';
16
16
  import { atomicWriteFileSync } from './fs-atomic.js';
17
- import { ALL_AGENT_IDS } from './agents.js';
17
+ import { ALL_AGENT_IDS, ROUTINE_AGENT_IDS } from './agents.js';
18
18
  import { machineId, normalizeHost } from './machine-id.js';
19
19
  import { resolveActor } from './actor.js';
20
20
  import { percentile } from './percentile.js';
@@ -600,6 +600,7 @@ export function validateJob(config) {
600
600
  const hasAgent = Boolean(config.agent && typeof config.agent === 'string');
601
601
  const hasWorkflow = Boolean(config.workflow && typeof config.workflow === 'string');
602
602
  const hasCommand = Boolean(config.command && typeof config.command === 'string');
603
+ const strategy = resolveHostStrategy(config);
603
604
  const set = [hasAgent, hasWorkflow, hasCommand].filter(Boolean).length;
604
605
  if (set === 0) {
605
606
  errors.push('exactly one of agent, workflow, or command is required');
@@ -613,6 +614,18 @@ export function validateJob(config) {
613
614
  if (hasAgent && config.agent && !ALL_AGENT_IDS.includes(config.agent)) {
614
615
  errors.push(`agent must be one of: ${ALL_AGENT_IDS.join(', ')}`);
615
616
  }
617
+ else if (hasAgent && config.agent && strategy === 'local' &&
618
+ !ROUTINE_AGENT_IDS.includes(config.agent)) {
619
+ // The local daemon only knows how to build a command for the agents in
620
+ // ROUTINE_AGENT_IDS (runner.ts's AGENT_COMMANDS table) — anything else is a
621
+ // real, installable agent (it passed the ALL_AGENT_IDS check above) but one
622
+ // the daemon can't fire itself, so reject it now instead of accepting the
623
+ // routine and failing at fire time (runner.ts buildJobCommand: "Unsupported
624
+ // agent for daemon jobs"). host/fleet/cloud placement dispatches through
625
+ // `agents run`/a cloud provider instead of this table, so they're exempt.
626
+ errors.push(`agent '${config.agent}' is not supported by the local routine daemon; use one of: ` +
627
+ `${ROUTINE_AGENT_IDS.join(', ')} (or set hostStrategy: host/fleet/cloud to run it elsewhere)`);
628
+ }
616
629
  if (hasWorkflow && config.workflow) {
617
630
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(config.workflow)) {
618
631
  errors.push('workflow must be a lowercase alphanumeric name (hyphens and underscores allowed, e.g. autodev)');
@@ -662,7 +675,6 @@ export function validateJob(config) {
662
675
  errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
663
676
  }
664
677
  }
665
- const strategy = resolveHostStrategy(config);
666
678
  if (config.host !== undefined) {
667
679
  if (typeof config.host !== 'string' || config.host.trim() === '') {
668
680
  errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
@@ -26,14 +26,19 @@ export interface RunResult {
26
26
  export declare class RoutineAlreadyRunningError extends Error {
27
27
  constructor(jobName: string, runId: string);
28
28
  }
29
- /** Agents the daemon can actually run, derived from the command table above
30
- * so the `--agent` help and any validation can never drift from it. */
31
- export declare const ROUTINE_AGENT_IDS: readonly string[];
32
29
  /** Stable working directory for routine children, independent of the daemon's launch cwd. */
33
30
  export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
34
31
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
35
32
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
36
- export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;
33
+ /**
34
+ * Record every transcript file already present in the child's transcript dirs BEFORE
35
+ * the run spawns. When the transcript lands in a shared per-version home, this baseline
36
+ * is what lets the archiver copy only the file THIS run produced instead of sweeping in
37
+ * every sibling session that home holds (which would mis-tag them all `origin='routine'`
38
+ * under this run's name). Called once per run, before spawn.
39
+ */
40
+ export declare function snapshotRoutineTranscriptBase(meta: Pick<RunMeta, 'jobName' | 'agent' | 'version'>, runDir: string, overlayHome?: string): void;
41
+ export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent' | 'version'>, runDir: string, overlayHome?: string): void;
37
42
  /** Pre-flight version/account selection for a routine job. */
38
43
  export interface RoutineLaunchPlan {
39
44
  /** Ordered attempts: primary first, then same-agent failover accounts. */
@@ -18,7 +18,8 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
20
  import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, } from './routines.js';
21
- import { getRunsDir } from './state.js';
21
+ import { getRunsDir, getUserAgentsDir } from './state.js';
22
+ import { shortCodexHome } from './codex-home.js';
22
23
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
24
  import { resolveModel, buildReasoningFlags } from './models.js';
24
25
  import { createTimer, redactPrompt } from './events.js';
@@ -36,7 +37,7 @@ import { resolveClaudeSetupToken } from './claude-account-token.js';
36
37
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
37
38
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
38
39
  import { machineId } from './machine-id.js';
39
- import { isSelfUpdatingAgent } from './agents.js';
40
+ import { isSelfUpdatingAgent, ROUTINE_AGENT_COMMANDS as AGENT_COMMANDS, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from './agents.js';
40
41
  import { expandLocalHome, getProjectRoot } from './project-root.js';
41
42
  export class RoutineAlreadyRunningError extends Error {
42
43
  constructor(jobName, runId) {
@@ -98,19 +99,6 @@ function terminateRoutineTree(pid) {
98
99
  }
99
100
  catch { /* already exited */ }
100
101
  }
101
- /** CLI command templates per agent, with {prompt} as a placeholder. */
102
- const AGENT_COMMANDS = {
103
- claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
104
- codex: ['codex', 'exec', '{prompt}', '--json'],
105
- gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
106
- cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
107
- kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
108
- droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
109
- muse: ['muse', 'exec', '{prompt}', '--json'],
110
- };
111
- /** Agents the daemon can actually run, derived from the command table above
112
- * so the `--agent` help and any validation can never drift from it. */
113
- export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(AGENT_COMMANDS));
114
102
  /**
115
103
  * Where each agent's transcript files live under an overlay HOME, mirroring
116
104
  * `SESSION_ROOT_SPECS` (session/discover.ts) — the CLI's own source of truth
@@ -322,6 +310,94 @@ function appendModelAndReasoning(cmd, config) {
322
310
  function generateRunId() {
323
311
  return new Date().toISOString().replace(/[:.]/g, '-');
324
312
  }
313
+ /**
314
+ * Agents whose config dir `buildExecEnv` relocates OUT of the sandbox overlay HOME
315
+ * into their per-version home (exec.ts: CLAUDE_CONFIG_DIR / CODEX_HOME). A routine
316
+ * spawned for one of these writes its transcript under the version home, NOT the overlay
317
+ * the sandbox generated — so the archiver has to read it there. Both are version-pinned
318
+ * (never self-updating), so `RunMeta.version` names a real home.
319
+ *
320
+ * Scoped to the agents whose archived transcript the DISCOVERY side can already index as
321
+ * origin='routine' — `readRoutineArchiveMeta` (session/discover.ts) has a branch for
322
+ * claude and codex but not kimi. Kimi also relocates (KIMI_CODE_HOME) and hits the same
323
+ * bug, but archiving it here without a discovery branch would only copy files that are
324
+ * never indexed; adding a kimi reader (its session spans state.json + wire.jsonl under a
325
+ * `session_<uuid>` dir) is the separate follow-up that lets kimi join this set. Muse
326
+ * (XDG, self-updating) and copilot (no transcript spec) are likewise out of scope.
327
+ */
328
+ const CONFIG_DIR_RELOCATED_AGENTS = new Set(['claude', 'codex']);
329
+ /** Whether this run's transcript lands in a SHARED per-version home (accumulating every
330
+ * session that version+account ever ran) rather than the run's own disposable overlay. */
331
+ function usesSharedTranscriptHome(agent, version) {
332
+ return Boolean(version) && CONFIG_DIR_RELOCATED_AGENTS.has(agent);
333
+ }
334
+ /**
335
+ * The directories the child actually writes a transcript to for one spec — resolved to
336
+ * match `buildExecEnv` (exec.ts), the single decision-point for where the transcript
337
+ * lands, so the archiver can never drift from it. For a config-dir-relocated agent that
338
+ * is the per-version home (`.claude`, `.codex`, …); for everyone else it is the sandbox
339
+ * overlay HOME. Codex may run from a SUN_LEN-safe short home on macOS (codex-home.ts),
340
+ * so both candidates are returned and the caller skips whichever does not exist.
341
+ */
342
+ function routineTranscriptSourceRoots(agent, version, overlayHome, spec) {
343
+ if (usesSharedTranscriptHome(agent, version)) {
344
+ const roots = [path.join(getVersionHomePath(agent, version), ...spec.root)];
345
+ if (agent === 'codex') {
346
+ roots.push(path.join(shortCodexHome(getUserAgentsDir(), version), ...spec.root.slice(1)));
347
+ }
348
+ return roots;
349
+ }
350
+ return [path.join(overlayHome, ...spec.root)];
351
+ }
352
+ /** Path of the per-run baseline recorded before spawn (see {@link snapshotRoutineTranscriptBase}). */
353
+ function transcriptBasePath(runDir) {
354
+ return path.join(runDir, '.transcript-base.json');
355
+ }
356
+ /**
357
+ * Record every transcript file already present in the child's transcript dirs BEFORE
358
+ * the run spawns. When the transcript lands in a shared per-version home, this baseline
359
+ * is what lets the archiver copy only the file THIS run produced instead of sweeping in
360
+ * every sibling session that home holds (which would mis-tag them all `origin='routine'`
361
+ * under this run's name). Called once per run, before spawn.
362
+ */
363
+ export function snapshotRoutineTranscriptBase(meta, runDir, overlayHome) {
364
+ if (!meta.agent)
365
+ return;
366
+ const specs = ROUTINE_TRANSCRIPT_SPECS[meta.agent];
367
+ if (!specs)
368
+ return;
369
+ const home = overlayHome ?? getJobHomePath(meta.jobName);
370
+ const preexisting = new Set();
371
+ for (const spec of specs) {
372
+ for (const root of routineTranscriptSourceRoots(meta.agent, meta.version, home, spec)) {
373
+ if (!fs.existsSync(root))
374
+ continue;
375
+ for (const f of walkForFiles(root, spec.ext, 100_000))
376
+ preexisting.add(f);
377
+ }
378
+ }
379
+ try {
380
+ fs.mkdirSync(runDir, { recursive: true });
381
+ fs.writeFileSync(transcriptBasePath(runDir), JSON.stringify([...preexisting]), { mode: 0o600 });
382
+ }
383
+ catch {
384
+ // Best-effort: a missing baseline is handled conservatively at archive time
385
+ // (a shared home is skipped rather than bulk-copied).
386
+ }
387
+ }
388
+ /** Read the pre-spawn baseline. `null` means none was recorded (older run or a failed
389
+ * snapshot) — the archiver then refuses to bulk-copy a shared home. */
390
+ function readTranscriptBase(runDir) {
391
+ try {
392
+ const arr = JSON.parse(fs.readFileSync(transcriptBasePath(runDir), 'utf-8'));
393
+ if (Array.isArray(arr))
394
+ return new Set(arr.filter((x) => typeof x === 'string'));
395
+ }
396
+ catch {
397
+ // No baseline file, or it was unreadable/corrupt.
398
+ }
399
+ return null;
400
+ }
325
401
  export function archiveRoutineTranscripts(meta, runDir, overlayHome) {
326
402
  if (!meta.agent)
327
403
  return;
@@ -329,24 +405,35 @@ export function archiveRoutineTranscripts(meta, runDir, overlayHome) {
329
405
  if (!specs)
330
406
  return;
331
407
  const home = overlayHome ?? getJobHomePath(meta.jobName);
408
+ const shared = usesSharedTranscriptHome(meta.agent, meta.version);
409
+ const base = readTranscriptBase(runDir);
410
+ // A shared per-version home holds every session that version+account ran, so without a
411
+ // per-run baseline we cannot tell this run's transcript from a sibling's — copy nothing
412
+ // rather than mis-tag them all. A disposable overlay is this run's alone, so copy all.
413
+ if (shared && base === null)
414
+ return;
332
415
  for (const spec of specs) {
333
- const sourceRoot = path.join(home, ...spec.root);
334
- if (!fs.existsSync(sourceRoot))
335
- continue;
336
416
  const destRoot = path.join(runDir, 'sessions', meta.agent, spec.root[spec.root.length - 1]);
337
- for (const sourcePath of walkForFiles(sourceRoot, spec.ext, 100_000)) {
338
- const rel = path.relative(sourceRoot, sourcePath);
339
- if (rel.startsWith('..') || path.isAbsolute(rel))
417
+ for (const sourceRoot of routineTranscriptSourceRoots(meta.agent, meta.version, home, spec)) {
418
+ if (!fs.existsSync(sourceRoot))
340
419
  continue;
341
- const destPath = path.join(destRoot, rel);
342
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
343
- try {
344
- fs.copyFileSync(sourcePath, destPath);
345
- fs.chmodSync(destPath, 0o600);
346
- }
347
- catch {
348
- // The process already reached a terminal state; a concurrently removed
349
- // transcript should not rewrite that outcome.
420
+ for (const sourcePath of walkForFiles(sourceRoot, spec.ext, 100_000)) {
421
+ // Skip files that predate this run (a sibling session in the shared home).
422
+ if (base?.has(sourcePath))
423
+ continue;
424
+ const rel = path.relative(sourceRoot, sourcePath);
425
+ if (rel.startsWith('..') || path.isAbsolute(rel))
426
+ continue;
427
+ const destPath = path.join(destRoot, rel);
428
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
429
+ try {
430
+ fs.copyFileSync(sourcePath, destPath);
431
+ fs.chmodSync(destPath, 0o600);
432
+ }
433
+ catch {
434
+ // The process already reached a terminal state; a concurrently removed
435
+ // transcript should not rewrite that outcome.
436
+ }
350
437
  }
351
438
  }
352
439
  }
@@ -680,11 +767,53 @@ function injectRoutineActor(env, config) {
680
767
  env.AGENTS_ACTOR = config.actor;
681
768
  return env;
682
769
  }
770
+ /**
771
+ * `routines add`/`routines edit` refuse to create a routine pinned to a
772
+ * hard-deprecated harness (commands/routines.ts), but that check runs only at
773
+ * CLI-write time. A routine YAML written before the harness was deprecated, or
774
+ * edited directly on disk / synced from another device, has no equivalent gate
775
+ * at fire time — the daemon would happily build and spawn `gemini …` against a
776
+ * backend Google retired. Fail loud here, before any version/account
777
+ * resolution or sandbox prep, so a legacy routine skips with the same message
778
+ * every other entry point (`sync`, `exec`, `import`, `run-cloud`, `versions`)
779
+ * already shows instead of a doomed, confusing spawn failure.
780
+ */
781
+ function hardDeprecationRunFailure(config, agent) {
782
+ const runId = generateRunId();
783
+ const runDir = getRunDir(config.name, runId);
784
+ fs.mkdirSync(runDir, { recursive: true });
785
+ const reason = hardDeprecationError(agent);
786
+ const meta = {
787
+ jobName: config.name,
788
+ runId,
789
+ ...runProvenance(config),
790
+ agent,
791
+ pid: null,
792
+ spawnedAt: Date.now(),
793
+ status: 'running',
794
+ startedAt: new Date().toISOString(),
795
+ completedAt: null,
796
+ exitCode: null,
797
+ };
798
+ writeRunMeta(meta);
799
+ process.stderr.write(`[agents] routine ${config.name}: ${reason}\n`);
800
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
801
+ writeRunMeta(meta);
802
+ return meta;
803
+ }
683
804
  export async function executeJob(config, deps) {
684
805
  const eligibility = checkJobDeviceEligibility(config);
685
806
  if (eligibility) {
686
807
  throw new Error(eligibility.message);
687
808
  }
809
+ // Hard-deprecated harness (e.g. gemini): fail loud before placement, version/
810
+ // account resolution, or sandbox prep — local, host, AND cloud placement all
811
+ // share this one gate (a hard-deprecated agent has no `cloudProvider` entry,
812
+ // so cloud placement would otherwise silently fall back to the default
813
+ // provider instead of refusing). See hardDeprecationRunFailure.
814
+ if (!config.workflow && config.agent && ROUTINE_AGENT_IDS.includes(config.agent) && isAgentHardDeprecated(config.agent)) {
815
+ return { meta: hardDeprecationRunFailure(config, config.agent), reportPath: null };
816
+ }
688
817
  // Placement (hostStrategy / bare host:) — body may run on another machine
689
818
  // over SSH or in the cloud; local version selection / sandbox / spawn then
690
819
  // do not apply. Sync callers (manual `routines run`, catchup) follow the
@@ -737,6 +866,7 @@ export async function executeJob(config, deps) {
737
866
  runId,
738
867
  ...runProvenance(config),
739
868
  agent: effectiveAgent,
869
+ version: primaryVersion,
740
870
  ...(config.workflow ? { workflow: config.workflow } : {}),
741
871
  pid: null,
742
872
  spawnedAt: Date.now(),
@@ -746,6 +876,9 @@ export async function executeJob(config, deps) {
746
876
  exitCode: null,
747
877
  };
748
878
  writeRunMeta(meta);
879
+ // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
880
+ // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
881
+ snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
749
882
  // Auth preflight: if the last live probe rejected this (agent, version)'s
750
883
  // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
751
884
  // before spawning instead of producing a doomed run + poisoned report. Cache-
@@ -821,6 +954,13 @@ export async function executeJob(config, deps) {
821
954
  if (i === 0) {
822
955
  process.stderr.write(`[agents] routine ${config.name}: running ${label}\n`);
823
956
  }
957
+ // A rate-limit failover spawns the NEXT chain entry, whose version/account has
958
+ // its own per-version transcript home. Re-point meta.version at the attempt that
959
+ // is about to run and re-baseline that home, so the archiver reads the transcript
960
+ // where THIS attempt writes it — not chain[0]'s home (RUSH-2271). The pre-loop
961
+ // snapshot already covered chain[0]; this makes every later attempt correct too.
962
+ meta.version = attemptVersion;
963
+ snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
824
964
  const viaAgentsRun = dispatchesViaAgentsRun(config);
825
965
  const cmd = viaAgentsRun
826
966
  ? baseCmd
@@ -1164,6 +1304,14 @@ export async function executeJobDetached(config, hooks) {
1164
1304
  return withRoutineLaunchClaim(config, () => executeJobDetachedClaimed(config, hooks));
1165
1305
  }
1166
1306
  async function executeJobDetachedClaimed(config, hooks) {
1307
+ // Hard-deprecated harness (e.g. gemini): fail loud before placement, version/
1308
+ // account resolution, or sandbox prep — local, host, AND cloud placement all
1309
+ // share this one gate (a hard-deprecated agent has no `cloudProvider` entry,
1310
+ // so cloud placement would otherwise silently fall back to the default
1311
+ // provider instead of refusing). See hardDeprecationRunFailure.
1312
+ if (!config.workflow && config.agent && ROUTINE_AGENT_IDS.includes(config.agent) && isAgentHardDeprecated(config.agent)) {
1313
+ return hardDeprecationRunFailure(config, config.agent);
1314
+ }
1167
1315
  // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
1168
1316
  // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
1169
1317
  // Either way the in-process onFinish hook does not fire for off-box routines
@@ -1236,6 +1384,7 @@ async function executeJobDetachedClaimed(config, hooks) {
1236
1384
  runId,
1237
1385
  ...runProvenance(config),
1238
1386
  agent: effectiveAgent,
1387
+ version,
1239
1388
  ...(config.workflow ? { workflow: config.workflow } : {}),
1240
1389
  pid: null,
1241
1390
  spawnedAt: Date.now(),
@@ -1245,6 +1394,9 @@ async function executeJobDetachedClaimed(config, hooks) {
1245
1394
  completedAt: null,
1246
1395
  exitCode: null,
1247
1396
  };
1397
+ // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
1398
+ // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
1399
+ snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1248
1400
  // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1249
1401
  // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1250
1402
  // login. If the last live probe rejected that (agent, version)'s token, the run
@@ -31,5 +31,3 @@ export declare function symlinkAllowedDirs(overlayHome: string, dirs: string[]):
31
31
  export declare function generateClaudeConfig(overlayHome: string, config: JobConfig): void;
32
32
  /** Generate a Codex config.toml in the overlay with model and approval-mode settings. */
33
33
  export declare function generateCodexConfig(overlayHome: string, config: JobConfig): void;
34
- /** Generate a Gemini settings.json in the overlay from the job's config block. */
35
- export declare function generateGeminiConfig(overlayHome: string, config: JobConfig): void;