@phnx-labs/agents-cli 1.20.52 → 1.20.53

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 (107) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +10 -1
  18. package/dist/commands/worktree.js +4 -2
  19. package/dist/index.js +16 -21
  20. package/dist/lib/agents.js +249 -17
  21. package/dist/lib/answer-router.d.ts +75 -0
  22. package/dist/lib/answer-router.js +149 -0
  23. package/dist/lib/ask-classifier.d.ts +71 -0
  24. package/dist/lib/ask-classifier.js +197 -0
  25. package/dist/lib/cloud/antigravity.d.ts +0 -2
  26. package/dist/lib/cloud/antigravity.js +2 -17
  27. package/dist/lib/cloud/codex.js +3 -18
  28. package/dist/lib/cloud/rush.js +3 -15
  29. package/dist/lib/cloud/stream.js +2 -0
  30. package/dist/lib/cloud/types.d.ts +21 -0
  31. package/dist/lib/cloud/types.js +81 -0
  32. package/dist/lib/crabbox/cli.d.ts +1 -1
  33. package/dist/lib/crabbox/cli.js +12 -2
  34. package/dist/lib/crabbox/lease.d.ts +13 -0
  35. package/dist/lib/crabbox/lease.js +11 -2
  36. package/dist/lib/crabbox/progress.d.ts +62 -0
  37. package/dist/lib/crabbox/progress.js +129 -0
  38. package/dist/lib/events.js +4 -1
  39. package/dist/lib/exec.js +19 -1
  40. package/dist/lib/feed-outcome.d.ts +101 -0
  41. package/dist/lib/feed-outcome.js +244 -0
  42. package/dist/lib/feed-policy.d.ts +30 -0
  43. package/dist/lib/feed-policy.js +133 -0
  44. package/dist/lib/feed.d.ts +127 -3
  45. package/dist/lib/feed.js +416 -40
  46. package/dist/lib/git.d.ts +17 -1
  47. package/dist/lib/git.js +20 -1
  48. package/dist/lib/hooks.js +522 -12
  49. package/dist/lib/hosts/passthrough.d.ts +3 -3
  50. package/dist/lib/hosts/passthrough.js +3 -4
  51. package/dist/lib/mailbox-gc.d.ts +22 -0
  52. package/dist/lib/mailbox-gc.js +161 -0
  53. package/dist/lib/mailbox.d.ts +26 -2
  54. package/dist/lib/mailbox.js +80 -5
  55. package/dist/lib/mcp.js +82 -0
  56. package/dist/lib/memory.d.ts +55 -0
  57. package/dist/lib/memory.js +274 -0
  58. package/dist/lib/notify.d.ts +16 -0
  59. package/dist/lib/notify.js +61 -0
  60. package/dist/lib/operator.d.ts +26 -0
  61. package/dist/lib/operator.js +107 -0
  62. package/dist/lib/plugins.d.ts +35 -0
  63. package/dist/lib/plugins.js +217 -0
  64. package/dist/lib/remote-agents-json.d.ts +14 -0
  65. package/dist/lib/remote-agents-json.js +94 -0
  66. package/dist/lib/resources/mcp.js +44 -0
  67. package/dist/lib/resources/memory.d.ts +15 -0
  68. package/dist/lib/resources/memory.js +46 -0
  69. package/dist/lib/resources/types.d.ts +2 -2
  70. package/dist/lib/runner.d.ts +43 -0
  71. package/dist/lib/runner.js +323 -74
  72. package/dist/lib/sandbox.js +6 -0
  73. package/dist/lib/secrets/bundles.js +38 -13
  74. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  75. package/dist/lib/secrets/icloud-import.js +37 -7
  76. package/dist/lib/secrets/index.d.ts +106 -2
  77. package/dist/lib/secrets/index.js +603 -28
  78. package/dist/lib/session/active.d.ts +18 -0
  79. package/dist/lib/session/active.js +47 -17
  80. package/dist/lib/session/db.d.ts +9 -1
  81. package/dist/lib/session/db.js +18 -3
  82. package/dist/lib/session/discover.d.ts +13 -0
  83. package/dist/lib/session/discover.js +31 -0
  84. package/dist/lib/session/parse.d.ts +8 -0
  85. package/dist/lib/session/parse.js +42 -21
  86. package/dist/lib/session/remote-active.js +8 -89
  87. package/dist/lib/session/state.d.ts +11 -0
  88. package/dist/lib/session/state.js +37 -0
  89. package/dist/lib/session/tail.d.ts +23 -4
  90. package/dist/lib/session/tail.js +34 -16
  91. package/dist/lib/session/throughput.d.ts +30 -0
  92. package/dist/lib/session/throughput.js +86 -0
  93. package/dist/lib/shim-heal.d.ts +12 -3
  94. package/dist/lib/shim-heal.js +12 -6
  95. package/dist/lib/staleness/detectors/subagents.js +57 -3
  96. package/dist/lib/staleness/writers/hooks.js +7 -3
  97. package/dist/lib/staleness/writers/subagents.js +37 -6
  98. package/dist/lib/startup/command-registry.d.ts +1 -0
  99. package/dist/lib/startup/command-registry.js +2 -0
  100. package/dist/lib/subagents.d.ts +52 -0
  101. package/dist/lib/subagents.js +315 -12
  102. package/dist/lib/teams/worktree.d.ts +8 -0
  103. package/dist/lib/teams/worktree.js +8 -0
  104. package/dist/lib/types.d.ts +10 -2
  105. package/dist/lib/versions.js +38 -48
  106. package/package.json +4 -3
  107. package/scripts/postinstall.js +61 -1
@@ -5,6 +5,13 @@
5
5
  * sandboxed or unsandboxed environments, captures stdout to log files,
6
6
  * enforces timeouts, and extracts the final assistant report from the
7
7
  * agent's stream-JSON output.
8
+ *
9
+ * Version/account selection mirrors `agents run`: when a routine does not pin
10
+ * `version:`, the runner uses the configured run strategy (default `balanced`)
11
+ * to pick a healthy install, pins the absolute binary via `getBinaryPath`, and
12
+ * arms same-agent failover across other healthy accounts when a rate/usage
13
+ * limit is detected mid-run (foreground `executeJob` only — detached daemon
14
+ * fires once with the pre-flight pick).
8
15
  */
9
16
  import { spawn } from 'child_process';
10
17
  import * as fs from 'fs';
@@ -15,8 +22,10 @@ import { getRunsDir } from './state.js';
15
22
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
23
  import { resolveModel, buildReasoningFlags } from './models.js';
17
24
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
18
- import { normalizeMode } from './exec.js';
25
+ import { normalizeMode, buildExecEnv, detectRateLimit, } from './exec.js';
19
26
  import { backgroundSpawnOptions } from './platform/process.js';
27
+ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
28
+ import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
20
29
  /** CLI command templates per agent, with {prompt} as a placeholder. */
21
30
  const AGENT_COMMANDS = {
22
31
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -161,6 +170,201 @@ function appendModelAndReasoning(cmd, config) {
161
170
  function generateRunId() {
162
171
  return new Date().toISOString().replace(/[:.]/g, '-');
163
172
  }
173
+ /**
174
+ * Resolve the version/account chain for a routine the same way `agents run`
175
+ * does: honor an explicit `version:` pin; otherwise use the configured run
176
+ * strategy (default `balanced`) so credit-exhausted / rate-limited accounts
177
+ * are skipped pre-flight, and synthesize a same-agent failover chain from the
178
+ * other healthy accounts for mid-run rate limits.
179
+ *
180
+ * Workflows are left alone — `agents run <workflow>` owns selection.
181
+ */
182
+ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
183
+ if (config.workflow) {
184
+ return { chain: [], rotation: null, pinned: false };
185
+ }
186
+ const agent = config.agent;
187
+ if (config.version) {
188
+ const version = config.version;
189
+ if (!isVersionInstalled(agent, version)) {
190
+ process.stderr.write(`[agents] routine ${config.name}: pinned ${agent}@${version} is not installed\n`);
191
+ }
192
+ return {
193
+ chain: [{ agent, version }],
194
+ rotation: null,
195
+ pinned: true,
196
+ };
197
+ }
198
+ const strategy = getConfiguredRunStrategy(agent, cwd);
199
+ let version;
200
+ let rotation = null;
201
+ try {
202
+ const resolved = await resolveRunVersion(agent, strategy, cwd);
203
+ version = resolved.version ?? undefined;
204
+ rotation = resolved.rotation;
205
+ if (rotation) {
206
+ const label = rotation.picked.email
207
+ ? `${rotation.picked.email} · ${agent}@${rotation.picked.version}`
208
+ : `${agent}@${rotation.picked.version}`;
209
+ const ratio = `${rotation.healthy.length} of ${rotation.healthy.length + rotation.excluded.length} healthy`;
210
+ process.stderr.write(`[agents] routine ${config.name}: ${strategy} picked ${label} (${ratio})\n`);
211
+ if (rotation.excluded.length > 0) {
212
+ const reasons = rotation.excluded
213
+ .map((c) => {
214
+ const r = readinessFromCandidate(c);
215
+ const why = r.ready ? 'deduped' : r.reason;
216
+ return `${c.agent}@${c.version}=${why}`;
217
+ })
218
+ .join(', ');
219
+ process.stderr.write(`[agents] routine ${config.name}: skipped ${reasons}\n`);
220
+ }
221
+ }
222
+ else if (!version) {
223
+ process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} found no usable ${agent} version; ` +
224
+ `falling back to default pin\n`);
225
+ }
226
+ }
227
+ catch (err) {
228
+ process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} skipped: ${err.message}\n`);
229
+ }
230
+ if (!version) {
231
+ version = resolveVersion(agent, cwd) ?? undefined;
232
+ }
233
+ if (!version) {
234
+ process.stderr.write(`[agents] routine ${config.name}: no version of ${agent} configured — ` +
235
+ `run: agents add ${agent}@<version> && agents use ${agent} <version>\n`);
236
+ return { chain: [{ agent }], rotation: null, pinned: false };
237
+ }
238
+ const failover = rotationFailoverChain(rotation, version);
239
+ if (failover.length > 0) {
240
+ const labels = failover.map((f) => `${f.agent}@${f.version}`).join(', ');
241
+ process.stderr.write(`[agents] routine ${config.name}: credit/rate-limit failover armed → ${labels}\n`);
242
+ }
243
+ return {
244
+ chain: [{ agent, version }, ...failover],
245
+ rotation,
246
+ pinned: false,
247
+ };
248
+ }
249
+ /**
250
+ * Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
251
+ * Bypasses the bare-name shim so a sandboxed HOME / missing default pin cannot
252
+ * surface as "agents: no version of X configured".
253
+ */
254
+ export function pinJobBinary(cmd, agent, version) {
255
+ if (!version || cmd.length === 0)
256
+ return cmd;
257
+ if (!isVersionInstalled(agent, version))
258
+ return cmd;
259
+ const binary = getBinaryPath(agent, version);
260
+ if (!binary || !fs.existsSync(binary))
261
+ return cmd;
262
+ const next = [...cmd];
263
+ next[0] = binary;
264
+ return next;
265
+ }
266
+ /**
267
+ * Merge sandbox/base env with the canonical per-version exec env
268
+ * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
269
+ * with `agents run`.
270
+ */
271
+ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
272
+ const execEnv = buildExecEnv({
273
+ agent,
274
+ version,
275
+ mode: 'plan',
276
+ effort: 'auto',
277
+ headless: true,
278
+ env: baseEnv,
279
+ });
280
+ const out = {};
281
+ for (const [k, v] of Object.entries(execEnv)) {
282
+ if (v !== undefined)
283
+ out[k] = v;
284
+ }
285
+ if (timezone)
286
+ out.TZ = timezone;
287
+ return out;
288
+ }
289
+ /**
290
+ * Spawn one attempt, capture logs to `attemptLogPath`, enforce timeout.
291
+ * Rate-limit scanning uses only this attempt's log (not prior failover output).
292
+ * The attempt log is also appended into `combinedLogPath` for a continuous trail.
293
+ */
294
+ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
295
+ // Isolate this attempt's output so detectRateLimit never sees prior attempts.
296
+ fs.writeFileSync(attemptLogPath, '', { mode: 0o600 });
297
+ const stdoutFd = fs.openSync(attemptLogPath, 'a', 0o600);
298
+ return new Promise((resolve) => {
299
+ const child = spawn(cmd[0], cmd.slice(1), {
300
+ stdio: ['ignore', stdoutFd, stdoutFd],
301
+ ...backgroundSpawnOptions({ fdStdio: true }),
302
+ env,
303
+ });
304
+ let settled = false;
305
+ const finish = (result) => {
306
+ if (settled)
307
+ return;
308
+ settled = true;
309
+ try {
310
+ fs.closeSync(stdoutFd);
311
+ }
312
+ catch { /* fd already closed */ }
313
+ let logText = '';
314
+ try {
315
+ logText = fs.readFileSync(attemptLogPath, 'utf-8');
316
+ }
317
+ catch { /* missing log */ }
318
+ if (combinedLogPath) {
319
+ try {
320
+ fs.appendFileSync(combinedLogPath, logText);
321
+ }
322
+ catch { /* best-effort trail */ }
323
+ }
324
+ resolve({ ...result, logText });
325
+ };
326
+ const timeoutTimer = setTimeout(() => {
327
+ try {
328
+ if (child.pid)
329
+ process.kill(-child.pid, 'SIGTERM');
330
+ }
331
+ catch { /* process already exited */ }
332
+ setTimeout(() => {
333
+ try {
334
+ if (child.pid)
335
+ process.kill(-child.pid, 'SIGKILL');
336
+ }
337
+ catch { /* process already exited */ }
338
+ }, 5000);
339
+ finish({
340
+ exitCode: null,
341
+ status: 'timeout',
342
+ pid: child.pid || null,
343
+ logText: '',
344
+ });
345
+ }, timeoutMs);
346
+ child.on('exit', (code) => {
347
+ clearTimeout(timeoutTimer);
348
+ finish({
349
+ exitCode: code,
350
+ status: code === 0 ? 'completed' : 'failed',
351
+ pid: child.pid || null,
352
+ logText: '',
353
+ });
354
+ });
355
+ child.on('error', (err) => {
356
+ clearTimeout(timeoutTimer);
357
+ finish({
358
+ exitCode: 1,
359
+ status: 'failed',
360
+ error: err.message,
361
+ pid: child.pid || null,
362
+ logText: '',
363
+ });
364
+ });
365
+ child.unref();
366
+ });
367
+ }
164
368
  /**
165
369
  * Execute a job synchronously (waits for completion or timeout before resolving).
166
370
  *
@@ -169,12 +373,17 @@ function generateRunId() {
169
373
  * workflow `loop:` blocks (issue #400). The optional `deps` parameter provides
170
374
  * injectable seams (runIteration, sleep, writeCheckpoint) used by tests; production
171
375
  * callers omit it and get the defaults.
376
+ *
377
+ * Single-shot path: pre-flight version/account selection + mid-run rate-limit
378
+ * failover across healthy same-agent accounts (RUSH-1016).
172
379
  */
173
380
  export async function executeJob(config, deps) {
174
381
  maybeRotate();
382
+ const launch = await resolveRoutineLaunch(config);
383
+ const primaryVersion = launch.chain[0]?.version ?? config.version;
175
384
  const timer = createTimer('agent.run', {
176
385
  agent: config.agent,
177
- version: config.version,
386
+ version: primaryVersion,
178
387
  jobName: config.name,
179
388
  mode: config.mode,
180
389
  ...redactPrompt(config.prompt),
@@ -186,10 +395,9 @@ export async function executeJob(config, deps) {
186
395
  const runId = generateRunId();
187
396
  const runDir = getRunDir(config.name, runId);
188
397
  fs.mkdirSync(runDir, { recursive: true });
189
- let spawnEnv = useSandbox ? buildSpawnEnv(overlayHome) : { ...process.env };
190
- if (config.timezone) {
191
- spawnEnv.TZ = config.timezone;
192
- }
398
+ const baseEnv = useSandbox
399
+ ? buildSpawnEnv(overlayHome)
400
+ : { ...process.env };
193
401
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
194
402
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
195
403
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
@@ -208,9 +416,10 @@ export async function executeJob(config, deps) {
208
416
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
209
417
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
210
418
  if (config.loop) {
419
+ const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone);
211
420
  const execOptions = {
212
421
  agent: effectiveAgent,
213
- version: config.version,
422
+ version: primaryVersion,
214
423
  prompt: resolvedPrompt,
215
424
  mode: normalizeMode(config.mode),
216
425
  effort: config.effort,
@@ -229,7 +438,7 @@ export async function executeJob(config, deps) {
229
438
  runId,
230
439
  runDir,
231
440
  agent: effectiveAgent,
232
- version: config.version,
441
+ version: primaryVersion,
233
442
  }, deps);
234
443
  meta.status = loopResult.stoppedBy === 'error' ? 'failed' : 'completed';
235
444
  meta.completedAt = new Date().toISOString();
@@ -238,83 +447,105 @@ export async function executeJob(config, deps) {
238
447
  timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
239
448
  return { meta, reportPath: null };
240
449
  }
241
- // Single-shot path (no loop): build the command, open a log file, and spawn once.
242
- const cmd = buildJobCommand(config, resolvedPrompt);
450
+ // Single-shot path: build the command once, then walk the launch chain on
451
+ // rate/usage-limit failures (same detectRateLimit patterns as agents run).
452
+ const baseCmd = buildJobCommand(config, resolvedPrompt);
243
453
  const stdoutPath = path.join(runDir, 'stdout.log');
244
- const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
245
- return new Promise((resolve) => {
246
- const child = spawn(cmd[0], cmd.slice(1), {
247
- stdio: ['ignore', stdoutFd, stdoutFd],
248
- ...backgroundSpawnOptions({ fdStdio: true }),
249
- env: spawnEnv,
250
- });
251
- // Mark startup time (time from function call to process spawn)
252
- timer.mark('startup');
253
- meta.pid = child.pid || null;
454
+ // Truncate the log for a clean run; failover attempts append.
455
+ fs.writeFileSync(stdoutPath, '', { mode: 0o600 });
456
+ const chain = launch.chain.length > 0
457
+ ? launch.chain
458
+ : [{ agent: effectiveAgent, version: primaryVersion }];
459
+ timer.mark('startup');
460
+ for (let i = 0; i < chain.length; i++) {
461
+ const entry = chain[i];
462
+ const attemptAgent = entry.agent;
463
+ const attemptVersion = entry.version;
464
+ const label = attemptVersion ? `${attemptAgent}@${attemptVersion}` : attemptAgent;
465
+ if (i === 0) {
466
+ process.stderr.write(`[agents] routine ${config.name}: running ${label}\n`);
467
+ }
468
+ const cmd = config.workflow
469
+ ? baseCmd
470
+ : pinJobBinary(baseCmd, attemptAgent, attemptVersion);
471
+ const spawnEnv = config.workflow
472
+ ? (() => {
473
+ const e = { ...baseEnv };
474
+ if (config.timezone)
475
+ e.TZ = config.timezone;
476
+ return e;
477
+ })()
478
+ : buildRoutineSpawnEnv(baseEnv, attemptAgent, attemptVersion, config.timezone);
479
+ // Remaining timeout budget shared across failover attempts.
480
+ const elapsed = Date.now() - Date.parse(meta.startedAt);
481
+ const remaining = Math.max(1_000, timeoutMs - (Number.isFinite(elapsed) ? elapsed : 0));
482
+ const attemptLogPath = path.join(runDir, `stdout.attempt-${i}.log`);
483
+ const attempt = await spawnJobAttempt(cmd, spawnEnv, attemptLogPath, remaining, stdoutPath);
484
+ meta.pid = attempt.pid;
254
485
  writeRunMeta(meta);
255
- let settled = false;
256
- const timeoutTimer = setTimeout(() => {
257
- if (settled)
258
- return;
259
- settled = true;
260
- try {
261
- if (child.pid)
262
- process.kill(-child.pid, 'SIGTERM');
263
- }
264
- catch { /* process already exited */ }
265
- setTimeout(() => {
266
- try {
267
- if (child.pid)
268
- process.kill(-child.pid, 'SIGKILL');
269
- }
270
- catch { /* process already exited */ }
271
- }, 5000);
486
+ if (attempt.status === 'timeout') {
272
487
  meta.status = 'timeout';
273
488
  meta.completedAt = new Date().toISOString();
274
489
  writeRunMeta(meta);
275
490
  timer.end({ status: 'timeout', runId });
276
491
  const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
277
- resolve({ meta, reportPath });
278
- }, timeoutMs);
279
- child.on('exit', (code) => {
280
- if (settled)
281
- return;
282
- settled = true;
283
- clearTimeout(timeoutTimer);
284
- try {
285
- fs.closeSync(stdoutFd);
286
- }
287
- catch { /* fd already closed */ }
288
- meta.exitCode = code;
289
- meta.status = code === 0 ? 'completed' : 'failed';
492
+ return { meta, reportPath };
493
+ }
494
+ if (attempt.status === 'completed') {
495
+ meta.exitCode = 0;
496
+ meta.status = 'completed';
290
497
  meta.completedAt = new Date().toISOString();
291
498
  writeRunMeta(meta);
292
- timer.end({ status: meta.status, exitCode: code ?? undefined, runId });
499
+ timer.end({ status: 'completed', exitCode: 0, runId });
293
500
  const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
294
- resolve({ meta, reportPath });
295
- });
296
- child.on('error', (err) => {
297
- if (settled)
298
- return;
299
- settled = true;
300
- clearTimeout(timeoutTimer);
301
- try {
302
- fs.closeSync(stdoutFd);
303
- }
304
- catch { /* fd already closed */ }
305
- meta.status = 'failed';
306
- meta.completedAt = new Date().toISOString();
307
- writeRunMeta(meta);
308
- timer.end({ status: 'failed', error: err.message, runId });
309
- resolve({ meta, reportPath: null });
501
+ return { meta, reportPath };
502
+ }
503
+ // Failed — cascade only on rate/usage limit when more chain entries remain.
504
+ const isLast = i === chain.length - 1;
505
+ const rateLimited = detectRateLimit(attempt.logText) || (attempt.error ? detectRateLimit(attempt.error) : false);
506
+ if (!isLast && rateLimited) {
507
+ const next = chain[i + 1];
508
+ const nextLabel = next.version ? `${next.agent}@${next.version}` : next.agent;
509
+ process.stderr.write(`[agents] routine ${config.name}: ${label} failed with credit/rate limit, trying ${nextLabel}\n`);
510
+ fs.appendFileSync(stdoutPath, `\n[agents] ${label} hit rate/usage limit — failover → ${nextLabel}\n`);
511
+ continue;
512
+ }
513
+ if (attempt.error) {
514
+ process.stderr.write(`[agents] routine ${config.name}: spawn failed for ${label}: ${attempt.error}\n`);
515
+ }
516
+ meta.exitCode = attempt.exitCode ?? 1;
517
+ meta.status = 'failed';
518
+ meta.completedAt = new Date().toISOString();
519
+ writeRunMeta(meta);
520
+ timer.end({
521
+ status: 'failed',
522
+ exitCode: meta.exitCode ?? undefined,
523
+ runId,
524
+ ...(attempt.error ? { error: attempt.error } : {}),
310
525
  });
311
- child.unref();
312
- });
526
+ const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
527
+ return { meta, reportPath };
528
+ }
529
+ // Unreachable: chain is always non-empty, but keep a safe fallback.
530
+ meta.status = 'failed';
531
+ meta.exitCode = 1;
532
+ meta.completedAt = new Date().toISOString();
533
+ writeRunMeta(meta);
534
+ timer.end({ status: 'failed', exitCode: 1, runId });
535
+ return { meta, reportPath: null };
313
536
  }
314
537
  /** Spawn a job as a detached process and return immediately with run metadata. */
315
538
  export async function executeJobDetached(config) {
539
+ // Pre-flight: pick a healthy version/account so the daemon does not launch
540
+ // into a credit-exhausted install. Detached cannot mid-run failover (no exit
541
+ // wait); the next schedule tick re-selects if this attempt still fails.
542
+ const launch = await resolveRoutineLaunch(config);
543
+ const version = launch.chain[0]?.version ?? config.version;
316
544
  const resolvedPrompt = resolveJobPrompt(config);
317
- const cmd = buildJobCommand(config, resolvedPrompt);
545
+ let cmd = buildJobCommand(config, resolvedPrompt);
546
+ if (!config.workflow && version) {
547
+ cmd = pinJobBinary(cmd, config.agent, version);
548
+ }
318
549
  const useSandbox = config.sandbox !== false;
319
550
  const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
320
551
  const runId = generateRunId();
@@ -322,10 +553,17 @@ export async function executeJobDetached(config) {
322
553
  fs.mkdirSync(runDir, { recursive: true });
323
554
  const stdoutPath = path.join(runDir, 'stdout.log');
324
555
  const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
325
- let spawnEnv = useSandbox ? buildSpawnEnv(overlayHome) : { ...process.env };
326
- if (config.timezone) {
327
- spawnEnv.TZ = config.timezone;
328
- }
556
+ const baseEnv = useSandbox
557
+ ? buildSpawnEnv(overlayHome)
558
+ : { ...process.env };
559
+ const spawnEnv = config.workflow
560
+ ? (() => {
561
+ const e = { ...baseEnv };
562
+ if (config.timezone)
563
+ e.TZ = config.timezone;
564
+ return e;
565
+ })()
566
+ : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone);
329
567
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
330
568
  const meta = {
331
569
  jobName: config.name,
@@ -343,6 +581,17 @@ export async function executeJobDetached(config) {
343
581
  ...backgroundSpawnOptions({ fdStdio: true }),
344
582
  env: spawnEnv,
345
583
  });
584
+ child.on('error', (err) => {
585
+ try {
586
+ fs.closeSync(stdoutFd);
587
+ }
588
+ catch { /* fd already closed */ }
589
+ meta.status = 'failed';
590
+ meta.exitCode = 1;
591
+ meta.completedAt = new Date().toISOString();
592
+ writeRunMeta(meta);
593
+ process.stderr.write(`[agents] daemon: spawn failed for job "${config.name}": ${err.message}\n`);
594
+ });
346
595
  child.unref();
347
596
  try {
348
597
  fs.closeSync(stdoutFd);
@@ -43,6 +43,12 @@ const ENV_ALLOWLIST = [
43
43
  'VISUAL',
44
44
  'NO_COLOR',
45
45
  'FORCE_COLOR',
46
+ // Headless Claude auth: the routines daemon injects CLAUDE_CODE_OAUTH_TOKEN
47
+ // from the `claude` secrets bundle (see daemon.ts). Without this allowlist
48
+ // entry, sandboxed routine spawns drop the token and look "unconfigured".
49
+ // Intentionally NOT ANTHROPIC_API_KEY / other provider secrets — those stay
50
+ // stripped (see tests/sandbox.test.ts "does not include sensitive env vars").
51
+ 'CLAUDE_CODE_OAUTH_TOKEN',
46
52
  ];
47
53
  /** Tools safe to grant as wildcards (no filesystem access). */
48
54
  const SAFE_TOOLS = {
@@ -21,7 +21,7 @@ import * as fs from 'fs';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
23
  import * as yaml from 'yaml';
24
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
24
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainServiceAlias, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
@@ -328,6 +328,10 @@ export function writeBundle(bundle, opts = {}) {
328
328
  bundle.created_at = now;
329
329
  bundle.updated_at = now;
330
330
  const payload = {
331
+ // The bundle's own name, persisted since #316: with hashed service names
332
+ // the keychain item name is opaque, so listBundles recovers the display
333
+ // name from this field. Older CLIs drop unknown fields on read — safe.
334
+ name: bundle.name,
331
335
  description: bundle.description,
332
336
  allow_exec: bundle.allow_exec ? true : undefined,
333
337
  backend: backend === 'file' ? 'file' : undefined,
@@ -376,8 +380,14 @@ export function deleteBundle(name) {
376
380
  * posture listBundles wants (skip malformed / invalid-key bundles rather than
377
381
  * throw). `backend` is authoritative from where the item was found. Returns
378
382
  * null to skip.
383
+ *
384
+ * `nameHint` is the name recovered from a cleartext service name (Linux, the
385
+ * file store, pre-re-key items) — authoritative when present, and the only
386
+ * source for legacy metadata that predates the persisted `name` field. With
387
+ * hashed service names (macOS, #316) the hint is undefined and the name comes
388
+ * from the JSON payload written by writeBundle.
379
389
  */
380
- function parseBundleMeta(name, json, backend) {
390
+ function parseBundleMeta(nameHint, json, backend) {
381
391
  let parsed;
382
392
  try {
383
393
  parsed = JSON.parse(json);
@@ -387,6 +397,9 @@ function parseBundleMeta(name, json, backend) {
387
397
  }
388
398
  if (!parsed || typeof parsed !== 'object')
389
399
  return null;
400
+ const name = nameHint ?? (typeof parsed.name === 'string' ? parsed.name : undefined);
401
+ if (!name || !BUNDLE_NAME_PATTERN.test(name))
402
+ return null;
390
403
  const bundle = {
391
404
  name,
392
405
  description: parsed.description,
@@ -431,10 +444,12 @@ export function listBundles() {
431
444
  catch {
432
445
  keychainServices = [];
433
446
  }
434
- const keychainNames = keychainServices
435
- .map((s) => s.slice(BUNDLE_META_PREFIX.length))
436
- .filter((n) => BUNDLE_NAME_PATTERN.test(n));
437
- if (keychainNames.length > 0) {
447
+ // With hashed service names (macOS, #316) the enumerated services are
448
+ // opaque (`agents-cli.h.<ns>.m`) — the display name is recovered from the
449
+ // metadata JSON after the batch read below. Cleartext services (Linux,
450
+ // pre-re-key items) still carry the name; it's kept as the parse hint so
451
+ // legacy metadata without the persisted `name` field keeps listing.
452
+ if (keychainServices.length > 0) {
438
453
  // Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
439
454
  // so the getKeychainTokens batch below pops Touch ID on every `secrets
440
455
  // list` — the broker/`daily` mechanism only ever covered value reads, not
@@ -452,7 +467,7 @@ export function listBundles() {
452
467
  !isKeychainBackendOverridden() &&
453
468
  secretsAgentAutoEnabled();
454
469
  const nameSetHash = createHash('sha256')
455
- .update([...keychainNames].sort().join('\n'))
470
+ .update([...keychainServices].sort().join('\n'))
456
471
  .digest('hex')
457
472
  .slice(0, 32);
458
473
  const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
@@ -461,13 +476,16 @@ export function listBundles() {
461
476
  out.push(bundle);
462
477
  }
463
478
  else {
464
- const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
479
+ const fetched = getKeychainTokens(keychainServices);
465
480
  const keychainBundles = [];
466
- for (const name of keychainNames) {
467
- const json = fetched.get(bundleMetaItem(name));
481
+ for (const service of keychainServices) {
482
+ const json = fetched.get(service);
468
483
  if (json === undefined)
469
484
  continue;
470
- const bundle = parseBundleMeta(name, json, 'keychain');
485
+ const nameHint = service.startsWith(BUNDLE_META_PREFIX)
486
+ ? service.slice(BUNDLE_META_PREFIX.length)
487
+ : undefined;
488
+ const bundle = parseBundleMeta(nameHint, json, 'keychain');
471
489
  if (bundle)
472
490
  keychainBundles.push(bundle);
473
491
  }
@@ -748,7 +766,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
748
766
  ? `read ${name} secrets (for ${opts.caller})`
749
767
  : `read ${name} secrets`;
750
768
  void reason;
751
- const fetched = store.getBatch([metaItem, ...secretItems]);
769
+ // secretItems are storage names as enumerated (opaque hashed names on macOS
770
+ // with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
771
+ // hashed inside getBatch. Deduped because the hashed enumeration spans the
772
+ // bundle's whole namespace.
773
+ const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
752
774
  const json = fetched.get(metaItem);
753
775
  if (json === undefined) {
754
776
  // For a file-backed bundle the metadata item is on disk (that's how
@@ -835,7 +857,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
835
857
  }
836
858
  if (p.ref.provider === 'keychain') {
837
859
  const item = secretsKeychainItem(bundle.name, p.ref.value);
838
- const value = fetched.get(item);
860
+ // The batch keys results by the names it was ASKED for: the cleartext
861
+ // metaItem, plus enumerated storage names. Look up the cleartext name
862
+ // first (Linux / file store), then its hashed storage alias (macOS).
863
+ const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
839
864
  if (value === undefined) {
840
865
  throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
841
866
  `Run: agents secrets add ${bundle.name} ${key}`);
@@ -55,6 +55,12 @@ export interface ImportSyncedResult {
55
55
  skipped: number;
56
56
  /** Keys whose iCloud value could not be read (left in place, never purged). */
57
57
  missing: string[];
58
+ /**
59
+ * Keys the modern store refuses by policy (reserved env names like USER,
60
+ * loader/interpreter vars like DYLD_*) — the pre-cutover store accepted
61
+ * them. Left in iCloud, never purged: the iCloud item is the only copy.
62
+ */
63
+ unimportable: string[];
58
64
  purged: number;
59
65
  }
60
66
  /**
@@ -63,8 +69,11 @@ export interface ImportSyncedResult {
63
69
  * Values come from the synced secret items; the synced metadata item, when
64
70
  * present, contributes the description, literal vars, and non-keychain refs
65
71
  * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
66
- * them exactly). Existing local keys are skipped unless `force`. With `purge`,
67
- * only services whose value was successfully read are deleted from iCloud
68
- * an unreadable item is never destroyed.
72
+ * them exactly). Existing local keys are skipped unless `force`. Keys the
73
+ * modern store refuses by policy (reserved / loader env names the pre-cutover
74
+ * store accepted) are reported as `unimportable` instead of aborting the whole
75
+ * bundle. With `purge`, only services whose value provably lives locally
76
+ * (imported now, or skipped-because-present) are deleted from iCloud — an
77
+ * unreadable or unimportable item is never destroyed.
69
78
  */
70
79
  export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;