@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -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;
@@ -30,7 +30,15 @@ export declare class RoutineAlreadyRunningError extends Error {
30
30
  export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
31
31
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
32
32
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
33
- 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;
34
42
  /** Pre-flight version/account selection for a routine job. */
35
43
  export interface RoutineLaunchPlan {
36
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, ROUTINE_AGENT_COMMANDS as AGENT_COMMANDS } 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) {
@@ -309,6 +310,94 @@ function appendModelAndReasoning(cmd, config) {
309
310
  function generateRunId() {
310
311
  return new Date().toISOString().replace(/[:.]/g, '-');
311
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
+ }
312
401
  export function archiveRoutineTranscripts(meta, runDir, overlayHome) {
313
402
  if (!meta.agent)
314
403
  return;
@@ -316,24 +405,35 @@ export function archiveRoutineTranscripts(meta, runDir, overlayHome) {
316
405
  if (!specs)
317
406
  return;
318
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;
319
415
  for (const spec of specs) {
320
- const sourceRoot = path.join(home, ...spec.root);
321
- if (!fs.existsSync(sourceRoot))
322
- continue;
323
416
  const destRoot = path.join(runDir, 'sessions', meta.agent, spec.root[spec.root.length - 1]);
324
- for (const sourcePath of walkForFiles(sourceRoot, spec.ext, 100_000)) {
325
- const rel = path.relative(sourceRoot, sourcePath);
326
- if (rel.startsWith('..') || path.isAbsolute(rel))
417
+ for (const sourceRoot of routineTranscriptSourceRoots(meta.agent, meta.version, home, spec)) {
418
+ if (!fs.existsSync(sourceRoot))
327
419
  continue;
328
- const destPath = path.join(destRoot, rel);
329
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
330
- try {
331
- fs.copyFileSync(sourcePath, destPath);
332
- fs.chmodSync(destPath, 0o600);
333
- }
334
- catch {
335
- // The process already reached a terminal state; a concurrently removed
336
- // 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
+ }
337
437
  }
338
438
  }
339
439
  }
@@ -667,11 +767,53 @@ function injectRoutineActor(env, config) {
667
767
  env.AGENTS_ACTOR = config.actor;
668
768
  return env;
669
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
+ }
670
804
  export async function executeJob(config, deps) {
671
805
  const eligibility = checkJobDeviceEligibility(config);
672
806
  if (eligibility) {
673
807
  throw new Error(eligibility.message);
674
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
+ }
675
817
  // Placement (hostStrategy / bare host:) — body may run on another machine
676
818
  // over SSH or in the cloud; local version selection / sandbox / spawn then
677
819
  // do not apply. Sync callers (manual `routines run`, catchup) follow the
@@ -724,6 +866,7 @@ export async function executeJob(config, deps) {
724
866
  runId,
725
867
  ...runProvenance(config),
726
868
  agent: effectiveAgent,
869
+ version: primaryVersion,
727
870
  ...(config.workflow ? { workflow: config.workflow } : {}),
728
871
  pid: null,
729
872
  spawnedAt: Date.now(),
@@ -733,6 +876,9 @@ export async function executeJob(config, deps) {
733
876
  exitCode: null,
734
877
  };
735
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);
736
882
  // Auth preflight: if the last live probe rejected this (agent, version)'s
737
883
  // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
738
884
  // before spawning instead of producing a doomed run + poisoned report. Cache-
@@ -808,6 +954,13 @@ export async function executeJob(config, deps) {
808
954
  if (i === 0) {
809
955
  process.stderr.write(`[agents] routine ${config.name}: running ${label}\n`);
810
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);
811
964
  const viaAgentsRun = dispatchesViaAgentsRun(config);
812
965
  const cmd = viaAgentsRun
813
966
  ? baseCmd
@@ -1151,6 +1304,14 @@ export async function executeJobDetached(config, hooks) {
1151
1304
  return withRoutineLaunchClaim(config, () => executeJobDetachedClaimed(config, hooks));
1152
1305
  }
1153
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
+ }
1154
1315
  // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
1155
1316
  // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
1156
1317
  // Either way the in-process onFinish hook does not fire for off-box routines
@@ -1223,6 +1384,7 @@ async function executeJobDetachedClaimed(config, hooks) {
1223
1384
  runId,
1224
1385
  ...runProvenance(config),
1225
1386
  agent: effectiveAgent,
1387
+ version,
1226
1388
  ...(config.workflow ? { workflow: config.workflow } : {}),
1227
1389
  pid: null,
1228
1390
  spawnedAt: Date.now(),
@@ -1232,6 +1394,9 @@ async function executeJobDetachedClaimed(config, hooks) {
1232
1394
  completedAt: null,
1233
1395
  exitCode: null,
1234
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);
1235
1400
  // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1236
1401
  // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1237
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;
@@ -9,7 +9,6 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as os from 'os';
12
- import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
12
  import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
13
  import { safeJoin } from './paths.js';
15
14
  import { createLink } from './platform/index.js';
@@ -91,15 +90,14 @@ export function prepareJobHome(config) {
91
90
  const overlayHome = getJobHomePath(config.name);
92
91
  cleanJobHome(config.name);
93
92
  fs.mkdirSync(overlayHome, { recursive: true });
93
+ // gemini is hard-deprecated: runner.ts rejects a gemini routine before it
94
+ // ever reaches prepareJobHome, so there is deliberately no branch here.
94
95
  if (config.agent === 'claude') {
95
96
  generateClaudeConfig(overlayHome, config);
96
97
  }
97
98
  else if (config.agent === 'codex') {
98
99
  generateCodexConfig(overlayHome, config);
99
100
  }
100
- else if (config.agent === 'gemini') {
101
- generateGeminiConfig(overlayHome, config);
102
- }
103
101
  else if (config.agent === 'cursor') {
104
102
  generateCursorConfig(overlayHome);
105
103
  }
@@ -270,18 +268,3 @@ export function generateCodexConfig(overlayHome, config) {
270
268
  }
271
269
  fs.writeFileSync(path.join(codexDir, 'config.toml'), lines.join('\n') + '\n', 'utf-8');
272
270
  }
273
- /** Generate a Gemini settings.json in the overlay from the job's config block. */
274
- export function generateGeminiConfig(overlayHome, config) {
275
- const settingsPath = path.join(overlayHome, '.gemini', 'settings.json');
276
- updateGeminiSettings(settingsPath, (settings) => {
277
- if (config.config?.model) {
278
- settings.model = config.config.model;
279
- }
280
- if (config.config) {
281
- for (const [key, value] of Object.entries(config.config)) {
282
- settings[key] = value;
283
- }
284
- }
285
- setGeminiAutoUpdateDisabled(settings);
286
- });
287
- }