@phnx-labs/agents-cli 1.21.3 → 1.22.0

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 (73) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/exec.d.ts +27 -0
  6. package/dist/commands/exec.js +123 -6
  7. package/dist/commands/models.js +36 -1
  8. package/dist/commands/projects.js +22 -2
  9. package/dist/commands/sessions-backfill.d.ts +32 -0
  10. package/dist/commands/sessions-backfill.js +186 -0
  11. package/dist/commands/sessions.d.ts +17 -1
  12. package/dist/commands/sessions.js +317 -18
  13. package/dist/commands/teams.js +1 -1
  14. package/dist/commands/worktree.d.ts +3 -3
  15. package/dist/commands/worktree.js +35 -4
  16. package/dist/lib/daemon.d.ts +5 -1
  17. package/dist/lib/daemon.js +63 -14
  18. package/dist/lib/devices/resolve-target.d.ts +6 -0
  19. package/dist/lib/devices/resolve-target.js +9 -3
  20. package/dist/lib/exec.js +39 -8
  21. package/dist/lib/hosts/dispatch.d.ts +12 -0
  22. package/dist/lib/hosts/dispatch.js +23 -6
  23. package/dist/lib/hosts/reconnect.d.ts +38 -0
  24. package/dist/lib/hosts/reconnect.js +85 -4
  25. package/dist/lib/hosts/run-target.js +14 -2
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/model-tiers.d.ts +54 -0
  29. package/dist/lib/model-tiers.js +229 -0
  30. package/dist/lib/models.d.ts +3 -0
  31. package/dist/lib/models.js +44 -7
  32. package/dist/lib/pricing/prices.json +16 -1
  33. package/dist/lib/project-focus.d.ts +42 -0
  34. package/dist/lib/project-focus.js +80 -0
  35. package/dist/lib/project-schedule.d.ts +75 -0
  36. package/dist/lib/project-schedule.js +110 -0
  37. package/dist/lib/redact.d.ts +2 -0
  38. package/dist/lib/redact.js +22 -0
  39. package/dist/lib/remote-agents-json.d.ts +2 -0
  40. package/dist/lib/remote-agents-json.js +3 -3
  41. package/dist/lib/rotate.d.ts +84 -1
  42. package/dist/lib/rotate.js +155 -5
  43. package/dist/lib/runner.d.ts +4 -2
  44. package/dist/lib/runner.js +13 -4
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/session/bash-command.js +60 -9
  48. package/dist/lib/session/db.d.ts +7 -1
  49. package/dist/lib/session/db.js +301 -32
  50. package/dist/lib/session/discover.d.ts +40 -7
  51. package/dist/lib/session/discover.js +144 -83
  52. package/dist/lib/session/parse.d.ts +8 -1
  53. package/dist/lib/session/parse.js +83 -32
  54. package/dist/lib/session/remote-list.d.ts +71 -0
  55. package/dist/lib/session/remote-list.js +410 -2
  56. package/dist/lib/session/shell-programs.d.ts +15 -0
  57. package/dist/lib/session/shell-programs.js +359 -0
  58. package/dist/lib/session/tool-calls.d.ts +88 -0
  59. package/dist/lib/session/tool-calls.js +612 -0
  60. package/dist/lib/session/tool-index.d.ts +100 -0
  61. package/dist/lib/session/tool-index.js +773 -0
  62. package/dist/lib/session/tool-store.d.ts +15 -0
  63. package/dist/lib/session/tool-store.js +198 -0
  64. package/dist/lib/session/types.d.ts +7 -0
  65. package/dist/lib/state.d.ts +10 -1
  66. package/dist/lib/state.js +11 -2
  67. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  68. package/dist/lib/teams/remoteWorktree.js +3 -4
  69. package/dist/lib/teams/worktree.d.ts +11 -1
  70. package/dist/lib/teams/worktree.js +42 -4
  71. package/dist/lib/types.d.ts +17 -0
  72. package/dist/lib/types.js +17 -0
  73. package/package.json +3 -1
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Shared redaction helpers for text that may be exported or logged.
3
3
  */
4
+ /** Remove terminal control sequences from untrusted text before storage or display. */
5
+ export declare function sanitizeForTerminal(text: string): string;
4
6
  /**
5
7
  * Scrub secrets from `text`. Two passes: format-based patterns (above), then a
6
8
  * value-aware pass that masks any `knownValues` verbatim — a credential we
@@ -20,9 +20,31 @@ const SECRET_PATTERNS = [
20
20
  [/\bxapp-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
21
21
  [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
22
22
  [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
23
+ // Headers frequently appear inside shell arguments. Consume a quoted header
24
+ // as one unit so cookie attributes after a space do not survive redaction.
25
+ [/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*.*?\2/gi, '$1$2$3: [REDACTED]$2'],
26
+ [/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*(?:(?!\2).)*$/gi, '$1$2$3: [REDACTED]'],
23
27
  [/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
28
+ [/\b((?:Cookie|Set-Cookie)\s*:\s*)\S+/gi, '$1[REDACTED]'],
29
+ [/\b(Authorization\s*:\s*)(?!Bearer\s+\[REDACTED\])\S+(?:\s+\S+)?/gi, '$1[REDACTED]'],
30
+ // Structured secret fields can arrive as raw JSON output rather than an
31
+ // argument object, so the object walker alone is not sufficient.
32
+ [/(^[,{\s]|["'])([A-Z0-9_-]*(?:TOKEN|KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*:\s*)(["'][^"']*["']|[^,}\]\s]+)/gim, '$1$2"[REDACTED]"'],
33
+ [/(\s--?(?:password|token|secret|api[_-]?key)(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
34
+ [/(\s--user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
35
+ [/(\s-u\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
36
+ [/(\s--proxy-user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
37
+ [/(\s-U\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
38
+ [/(https?:\/\/)[^/\s:@]+:[^@\s/]+@/gi, '$1[REDACTED]@'],
24
39
  [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
25
40
  ];
41
+ const TERMINAL_ESCAPE_REGEX = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x9d[^\x07\x9c]*(?:\x07|\x9c)|\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
42
+ /** Remove terminal control sequences from untrusted text before storage or display. */
43
+ export function sanitizeForTerminal(text) {
44
+ if (!text)
45
+ return text;
46
+ return text.replace(TERMINAL_ESCAPE_REGEX, '');
47
+ }
26
48
  /** Env vars whose NAME marks their VALUE as a credential worth masking literally. */
27
49
  const SECRET_ENV_NAME = /(?:TOKEN|KEY|SECRET|PASSWORD)/i;
28
50
  /** Don't literal-mask trivially short values — they collide with ordinary text. */
@@ -10,6 +10,8 @@ export interface RemoteAgentsJsonOptions<T> {
10
10
  * printing a line per offline box above its output. Never a silent drop.
11
11
  */
12
12
  quiet?: boolean;
13
+ /** Per-peer deadline. Long-running maintenance commands override 12 seconds. */
14
+ timeoutMs?: number;
13
15
  }
14
16
  export interface RemoteAgentsJsonParseResult<T> {
15
17
  items: T[];
@@ -32,7 +32,7 @@ export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
32
32
  const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
33
33
  return `bash -lc ${shellQuote(inner)}`;
34
34
  }
35
- function sshCapture(target, remoteCmd) {
35
+ function sshCapture(target, remoteCmd, timeoutMs) {
36
36
  assertValidSshTarget(target);
37
37
  return new Promise((resolve) => {
38
38
  const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
@@ -49,7 +49,7 @@ function sshCapture(target, remoteCmd) {
49
49
  const timer = setTimeout(() => {
50
50
  child.kill('SIGKILL');
51
51
  done(null);
52
- }, REMOTE_TIMEOUT_MS);
52
+ }, timeoutMs);
53
53
  child.stdout.on('data', (data) => { stdout += data.toString(); });
54
54
  child.on('error', () => done(null));
55
55
  child.on('close', (code) => done(code));
@@ -102,7 +102,7 @@ export async function gatherRemoteAgentsJson(options) {
102
102
  const parseFailed = [];
103
103
  const results = await Promise.all(targets.map(async (target) => {
104
104
  const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
105
- const result = await sshCapture(target.target, command);
105
+ const result = await sshCapture(target.target, command, options.timeoutMs ?? REMOTE_TIMEOUT_MS);
106
106
  if (result.code !== 0) {
107
107
  skipped.push(target.name);
108
108
  if (!options.quiet) {
@@ -174,7 +174,81 @@ export declare function capacityWeight(usedPercent: number | null, minutesToLimi
174
174
  * usage headroom.
175
175
  */
176
176
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number): RotateResult | null;
177
+ /**
178
+ * Per-harness routing summary for `agents run auto` — the cross-harness layer
179
+ * that sits above `pickBalancedCandidate` (which is strictly per-harness).
180
+ */
181
+ export interface HarnessSummary {
182
+ agent: AgentId;
183
+ /** Every installed account slot probed for this harness. */
184
+ candidates: RotateCandidate[];
185
+ /** Healthy accounts after identity dedupe, sorted by headroom. */
186
+ healthy: RotateCandidate[];
187
+ /** The account this harness would route to (best verified headroom). Null when the harness is excluded. */
188
+ best: RotateCandidate | null;
189
+ /** Routing used% of `best` (max across non-session windows); null when unknown. */
190
+ bestUsedPercent: number | null;
191
+ /** Why the harness was excluded, e.g. ['2 rate_limited', '1 signed_out']. Empty when healthy. */
192
+ exclusionReasons: string[];
193
+ }
194
+ export interface HarnessPickResult {
195
+ /** The harness picked for this run. */
196
+ picked: HarnessSummary;
197
+ /** Harnesses with ≥1 healthy account (including the picked one). */
198
+ healthy: HarnessSummary[];
199
+ /** Harnesses with zero healthy accounts — excluded, not down-weighted. */
200
+ excluded: HarnessSummary[];
201
+ }
202
+ /**
203
+ * Classify every harness's candidates into healthy (with a representative
204
+ * best account) vs excluded (with per-reason counts). Pure — the pick and the
205
+ * zero-healthy error message both read this, so they can never disagree.
206
+ *
207
+ * Health uses the exact account-layer gate (`isRotationEligible`: signed in
208
+ * AND not maxed on ANY blocking window, weekly included). The representative
209
+ * best account honors `preferVerified`: confirmed headroom beats apparent
210
+ * headroom, the same freshness rule the account layer routes on.
211
+ */
212
+ export declare function classifyHarnessCandidates(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessSummary[];
213
+ /**
214
+ * Pick a harness for `agents run auto` using weighted random by best-account
215
+ * headroom (RUSH-2132).
216
+ *
217
+ * A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
218
+ * — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
219
+ * the representative best accounts, so host/harness/account layers all share
220
+ * one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
221
+ * not down-weighted. Returns null when no harness has any healthy account;
222
+ * call `classifyHarnessCandidates` for the exclusion detail to message with.
223
+ */
224
+ export declare function pickHarnessWeighted(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessPickResult | null;
225
+ /** One-line banner naming the auto-picked harness and why (headroom). */
226
+ export declare function formatHarnessPickBanner(result: HarnessPickResult): string;
227
+ /**
228
+ * The earliest FUTURE window reset across these candidates' usage snapshots —
229
+ * when the first exhausted account becomes usable again. Null when no snapshot
230
+ * carries a reset timestamp.
231
+ */
232
+ export declare function earliestResetAcross(candidates: RotateCandidate[], nowMs?: number): Date | null;
233
+ /**
234
+ * The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
235
+ * watchdog tail-detects this text: it must contain the literal `no healthy`
236
+ * and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
237
+ */
238
+ export declare function formatNoHealthyAccountError(agent: AgentId, strategy: RunStrategy, excluded: RotateCandidate[], nowMs?: number): string;
239
+ /**
240
+ * The zero-healthy-harness error for `agents run auto` — names each harness's
241
+ * exclusion reason plus the earliest reset across all snapshots.
242
+ */
243
+ export declare function formatNoHealthyHarnessError(summaries: HarnessSummary[], nowMs?: number): string;
177
244
  export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
245
+ /**
246
+ * Collect run candidates for every harness with ≥1 installed version — the
247
+ * probe `agents run auto` routes on (the same per-harness account probe
248
+ * `agents view` aggregates). Harnesses with nothing installed are absent from
249
+ * the map: not a candidate at all, rather than an excluded one.
250
+ */
251
+ export declare function collectHarnessCandidates(agentIds?: AgentId[]): Promise<Map<AgentId, RotateCandidate[]>>;
178
252
  /**
179
253
  * Resolve an account identity to the installed version slot that holds it, over
180
254
  * an already-collected candidate list. Pure — no I/O — so it is unit-tested
@@ -207,9 +281,18 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
207
281
  export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
208
282
  /** Select the configured version if available, otherwise another available version. */
209
283
  export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
210
- export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string): Promise<{
284
+ export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
211
285
  version: string | null;
212
286
  rotation: RotateResult | null;
287
+ /**
288
+ * Set when a non-pinned strategy found ZERO healthy candidates among the
289
+ * installed versions: the full excluded set, so callers fail loud with
290
+ * per-account reasons instead of launching the exhausted pinned default
291
+ * (RUSH-2132). Undefined for pinned, for successful picks, and when no
292
+ * version is installed at all (the pre-existing not-installed path — there
293
+ * is no account to be "unhealthy").
294
+ */
295
+ exhausted?: RotateCandidate[];
213
296
  }>;
214
297
  /**
215
298
  * Cap on the number of healthy accounts a single run will re-dispatch through
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { accountDisplayLabel, getAccountInfo } from './agents.js';
9
+ import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
@@ -342,6 +342,136 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
342
342
  : undefined;
343
343
  return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified };
344
344
  }
345
+ /**
346
+ * Classify every harness's candidates into healthy (with a representative
347
+ * best account) vs excluded (with per-reason counts). Pure — the pick and the
348
+ * zero-healthy error message both read this, so they can never disagree.
349
+ *
350
+ * Health uses the exact account-layer gate (`isRotationEligible`: signed in
351
+ * AND not maxed on ANY blocking window, weekly included). The representative
352
+ * best account honors `preferVerified`: confirmed headroom beats apparent
353
+ * headroom, the same freshness rule the account layer routes on.
354
+ */
355
+ export function classifyHarnessCandidates(byHarness, nowMs = Date.now()) {
356
+ const summaries = [];
357
+ for (const [agent, candidates] of byHarness) {
358
+ const eligible = candidates.filter(isRotationEligible);
359
+ if (eligible.length === 0) {
360
+ const counts = new Map();
361
+ for (const c of candidates) {
362
+ const readiness = readinessFromCandidate(c);
363
+ const reason = readiness.ready ? 'ineligible' : readiness.reason;
364
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
365
+ }
366
+ summaries.push({
367
+ agent,
368
+ candidates,
369
+ healthy: [],
370
+ best: null,
371
+ bestUsedPercent: null,
372
+ exclusionReasons: [...counts.entries()].map(([reason, n]) => `${n} ${reason}`),
373
+ });
374
+ continue;
375
+ }
376
+ const sorted = dedupeAndSortCandidates(eligible);
377
+ const { picked: best } = preferVerified(sorted, nowMs, (from) => from[0]);
378
+ summaries.push({
379
+ agent,
380
+ candidates,
381
+ healthy: sorted,
382
+ best,
383
+ bestUsedPercent: getRoutingUsedPercent(best.usageSnapshot),
384
+ exclusionReasons: [],
385
+ });
386
+ }
387
+ return summaries;
388
+ }
389
+ /**
390
+ * Pick a harness for `agents run auto` using weighted random by best-account
391
+ * headroom (RUSH-2132).
392
+ *
393
+ * A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
394
+ * — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
395
+ * the representative best accounts, so host/harness/account layers all share
396
+ * one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
397
+ * not down-weighted. Returns null when no harness has any healthy account;
398
+ * call `classifyHarnessCandidates` for the exclusion detail to message with.
399
+ */
400
+ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
401
+ const summaries = classifyHarnessCandidates(byHarness, nowMs);
402
+ const healthy = summaries.filter((s) => s.best !== null);
403
+ const excluded = summaries.filter((s) => s.best === null);
404
+ if (healthy.length === 0)
405
+ return null;
406
+ const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
407
+ const picked = healthy.find((s) => s.best === pickedBest);
408
+ return { picked, healthy, excluded };
409
+ }
410
+ /** One-line banner naming the auto-picked harness and why (headroom). */
411
+ export function formatHarnessPickBanner(result) {
412
+ const { picked, healthy, excluded } = result;
413
+ const headroom = picked.bestUsedPercent === null
414
+ ? 'best account headroom unknown'
415
+ : `best account ${Math.max(0, Math.round(100 - picked.bestUsedPercent))}% headroom`;
416
+ const ratio = `${healthy.length} of ${healthy.length + excluded.length} harnesses healthy`;
417
+ return `[agents] auto picked ${picked.agent} (${headroom}, ${ratio})`;
418
+ }
419
+ /**
420
+ * The earliest FUTURE window reset across these candidates' usage snapshots —
421
+ * when the first exhausted account becomes usable again. Null when no snapshot
422
+ * carries a reset timestamp.
423
+ */
424
+ export function earliestResetAcross(candidates, nowMs = Date.now()) {
425
+ let earliest = null;
426
+ for (const c of candidates) {
427
+ for (const window of c.usageSnapshot?.windows ?? []) {
428
+ const t = window.resetsAt?.getTime();
429
+ if (t != null && t > nowMs && (earliest === null || t < earliest)) {
430
+ earliest = t;
431
+ }
432
+ }
433
+ }
434
+ return earliest === null ? null : new Date(earliest);
435
+ }
436
+ /**
437
+ * The `resets <summary>` fragment both zero-healthy errors share. ISO 8601 so
438
+ * a watchdog can parse the cooldown straight off the line; `unknown` when no
439
+ * snapshot carries a reset (a parser falls back to its default cooldown).
440
+ */
441
+ function formatResetSummary(reset) {
442
+ return reset ? reset.toISOString() : 'unknown (no reset timestamps in any snapshot)';
443
+ }
444
+ /**
445
+ * The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
446
+ * watchdog tail-detects this text: it must contain the literal `no healthy`
447
+ * and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
448
+ */
449
+ export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = Date.now()) {
450
+ const excludedStr = excluded.length === 0
451
+ ? 'no installed versions'
452
+ : excluded.map((c) => {
453
+ const readiness = readinessFromCandidate(c);
454
+ const reason = readiness.ready ? 'ineligible' : readiness.reason;
455
+ return `${c.version} (${reason})`;
456
+ }).join(', ');
457
+ const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
458
+ return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
459
+ }
460
+ /**
461
+ * The zero-healthy-harness error for `agents run auto` — names each harness's
462
+ * exclusion reason plus the earliest reset across all snapshots.
463
+ */
464
+ export function formatNoHealthyHarnessError(summaries, nowMs = Date.now()) {
465
+ const excludedStr = summaries.length === 0
466
+ ? 'no installed harnesses'
467
+ : summaries.map((s) => {
468
+ const n = s.candidates.length;
469
+ const detail = s.exclusionReasons.length > 0 ? s.exclusionReasons.join(', ') : 'no accounts signed in';
470
+ return `${s.agent} (${n} account${n === 1 ? '' : 's'}: ${detail})`;
471
+ }).join(', ');
472
+ const resetSummary = formatResetSummary(earliestResetAcross(summaries.flatMap((s) => s.candidates), nowMs));
473
+ return `agents: no healthy harness for 'run auto' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Sign in an account or wait for a window to reset.`;
474
+ }
345
475
  export async function collectRunCandidates(agent) {
346
476
  const versions = listInstalledVersions(agent);
347
477
  const rows = await Promise.all(versions.map(async (version) => {
@@ -400,6 +530,25 @@ export async function collectRunCandidates(agent) {
400
530
  };
401
531
  });
402
532
  }
533
+ /**
534
+ * Collect run candidates for every harness with ≥1 installed version — the
535
+ * probe `agents run auto` routes on (the same per-harness account probe
536
+ * `agents view` aggregates). Harnesses with nothing installed are absent from
537
+ * the map: not a candidate at all, rather than an excluded one.
538
+ */
539
+ export async function collectHarnessCandidates(agentIds = ALL_AGENT_IDS) {
540
+ const entries = await Promise.all(agentIds.map(async (agent) => {
541
+ if (listInstalledVersions(agent).length === 0)
542
+ return null;
543
+ return [agent, await collectRunCandidates(agent)];
544
+ }));
545
+ const byHarness = new Map();
546
+ for (const entry of entries) {
547
+ if (entry)
548
+ byHarness.set(entry[0], entry[1]);
549
+ }
550
+ return byHarness;
551
+ }
403
552
  /**
404
553
  * Resolve an account identity to the installed version slot that holds it, over
405
554
  * an already-collected candidate list. Pure — no I/O — so it is unit-tested
@@ -478,14 +627,15 @@ function readRotationStamp(agent) {
478
627
  catch { /* missing or corrupt — treat as no stamp */ }
479
628
  return null;
480
629
  }
481
- export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
630
+ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
482
631
  const fallback = resolveVersion(agent, cwd);
483
632
  if (strategy === 'pinned') {
484
633
  return { version: fallback, rotation: null };
485
634
  }
635
+ const candidates = await collect(agent);
486
636
  const rotation = strategy === 'available'
487
- ? await selectAvailableVersion(agent, fallback)
488
- : await selectBalancedVersion(agent);
637
+ ? pickAvailableCandidate(candidates, fallback)
638
+ : pickBalancedCandidate(candidates);
489
639
  if (rotation) {
490
640
  // `available` is sticky to the pinned default when healthy. Use the 60s
491
641
  // anti-collision stamp to nudge parallel callers off the same version.
@@ -503,7 +653,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
503
653
  emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
504
654
  return { version: rotation.picked.version, rotation };
505
655
  }
506
- return { version: fallback, rotation: null };
656
+ return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
507
657
  }
508
658
  /**
509
659
  * Cap on the number of healthy accounts a single run will re-dispatch through
@@ -17,7 +17,7 @@ import type { JobConfig, RunMeta } from './routines.js';
17
17
  import type { AgentId } from './types.js';
18
18
  import { type FallbackEntry } from './exec.js';
19
19
  import type { LoopDeps } from './loop.js';
20
- import { type RotateResult } from './rotate.js';
20
+ import { resolveRunVersion, type RotateResult } from './rotate.js';
21
21
  /** Result of a completed job execution, including metadata and optional report. */
22
22
  export interface RunResult {
23
23
  meta: RunMeta;
@@ -49,7 +49,9 @@ export interface RoutineLaunchPlan {
49
49
  *
50
50
  * Workflows are left alone — `agents run <workflow>` owns selection.
51
51
  */
52
- export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string): Promise<RoutineLaunchPlan>;
52
+ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, deps?: {
53
+ resolveRunVersion?: typeof resolveRunVersion;
54
+ }): Promise<RoutineLaunchPlan>;
53
55
  /**
54
56
  * Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
55
57
  * Bypasses the bare-name shim so a sandboxed HOME / missing default pin cannot
@@ -29,7 +29,7 @@ import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
29
29
  import { backgroundSpawnOptions } from './platform/process.js';
30
30
  import { walkForFiles } from './fs-walk.js';
31
31
  import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
32
- import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
32
+ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
33
33
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
34
  import { machineId } from './machine-id.js';
35
35
  import { isSelfUpdatingAgent } from './agents.js';
@@ -330,7 +330,7 @@ function readCommandExitCode(runDir) {
330
330
  *
331
331
  * Workflows are left alone — `agents run <workflow>` owns selection.
332
332
  */
333
- export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
333
+ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {}) {
334
334
  if (config.workflow) {
335
335
  return { chain: [], rotation: null, pinned: false };
336
336
  }
@@ -364,10 +364,12 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
364
364
  const strategy = getConfiguredRunStrategy(agent, cwd);
365
365
  let version;
366
366
  let rotation = null;
367
+ let exhausted;
367
368
  try {
368
- const resolved = await resolveRunVersion(agent, strategy, cwd);
369
+ const resolved = await (deps.resolveRunVersion ?? resolveRunVersion)(agent, strategy, cwd);
369
370
  version = resolved.version ?? undefined;
370
371
  rotation = resolved.rotation;
372
+ exhausted = resolved.exhausted;
371
373
  if (rotation) {
372
374
  const label = rotation.picked.email
373
375
  ? `${rotation.picked.email} · ${agent}@${rotation.picked.version}`
@@ -385,7 +387,7 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
385
387
  process.stderr.write(`[agents] routine ${config.name}: skipped ${reasons}\n`);
386
388
  }
387
389
  }
388
- else if (!version) {
390
+ else if (!version && !exhausted) {
389
391
  process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} found no usable ${agent} version; ` +
390
392
  `falling back to default pin\n`);
391
393
  }
@@ -393,6 +395,13 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
393
395
  catch (err) {
394
396
  process.stderr.write(`[agents] routine ${config.name}: strategy ${strategy} skipped: ${err.message}\n`);
395
397
  }
398
+ // Zero healthy accounts is NOT a "fall back to the default pin" case — that
399
+ // pin is exactly the exhausted account an unattended routine would hammer
400
+ // every tick (RUSH-2132). Throwing fails the job run (nonzero), and the
401
+ // message text is the contract the Factory watchdog tail-detects.
402
+ if (exhausted) {
403
+ throw new Error(formatNoHealthyAccountError(agent, strategy, exhausted));
404
+ }
396
405
  if (!version) {
397
406
  version = resolveVersion(agent, cwd) ?? undefined;
398
407
  }
@@ -64,6 +64,7 @@ const TOOL_REGISTRY = {
64
64
  mv: { category: 'shell', signal: 'low', action: 'moving files' },
65
65
  cp: { category: 'shell', signal: 'low', action: 'copying files' },
66
66
  mkdir: { category: 'shell', signal: 'low', action: 'making directories' },
67
+ rmdir: { category: 'shell', signal: 'low', action: 'removing directories' },
67
68
  touch: { category: 'shell', signal: 'low', action: 'touching files' },
68
69
  echo: { category: 'shell', signal: 'low', action: 'echoing' },
69
70
  printf: { category: 'shell', signal: 'low', action: 'printing' },
@@ -115,6 +116,13 @@ const VALUE_FLAGS = {
115
116
  kubectl: new Set(['-n', '--namespace', '--kubeconfig', '--context', '--cluster', '--user', '-s', '--server', '--as', '--token', '--cache-dir', '--request-timeout']),
116
117
  rush: new Set(),
117
118
  openclaw: new Set(),
119
+ // The repo's own toolchain — heavy in real transcripts (`agents` alone was the
120
+ // top unrecognized token). Two-level so they bucket by subcommand
121
+ // (`agents sessions`, `linear list`) instead of one flat `other` pile (#1830).
122
+ // `ag` is deliberately NOT here — it is the silver searcher in TOOL_REGISTRY,
123
+ // not the agents alias, in this classifier's world.
124
+ agents: new Set(['-H', '--host', '--device']),
125
+ linear: new Set(),
118
126
  };
119
127
  /** Tools whose bucket key includes the second token (subcommand). */
120
128
  const TWO_LEVEL_TOOLS = new Set(Object.keys(VALUE_FLAGS));
@@ -141,8 +149,8 @@ export function unwrapCommand(cmd) {
141
149
  const prefix = s.match(/^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$/);
142
150
  if (prefix)
143
151
  return unwrapCommand(prefix[1]);
144
- // cd foo && command
145
- const cd = s.match(/^cd\s+\S+\s*&&\s*(.+)$/);
152
+ // cd foo && command (also `cd foo; command` and newline-separated `cd foo\ncommand`)
153
+ const cd = s.match(/^cd\s+\S+\s*(?:&&|;|\n)\s*([\s\S]+)$/);
146
154
  if (cd)
147
155
  return unwrapCommand(cd[1]);
148
156
  // npx / bunx
@@ -191,7 +199,11 @@ function splitOnOperators(cmd) {
191
199
  i += 2;
192
200
  continue;
193
201
  }
194
- if (ch === '|' || ch === ';') {
202
+ // A newline separates commands the same way `;` does (an unquoted, un-escaped
203
+ // line break), so `cd X\ncmd` splits into two segments instead of reading as
204
+ // one `cd` command — the top source of `other` classifications (#1830). A
205
+ // `\`-continued line never reaches here (handled by the escape branch above).
206
+ if (ch === '|' || ch === ';' || ch === '\n') {
195
207
  if (current.trim())
196
208
  parts.push(current.trim());
197
209
  current = '';
@@ -247,22 +259,61 @@ function scanSubcommand(tokens, tool) {
247
259
  }
248
260
  return '';
249
261
  }
262
+ /**
263
+ * The classifier reads only the executable and the first non-flag token, both at
264
+ * the very head of the first simple command. Tokenizing the *entire* command —
265
+ * every pipeline segment, multi-KB heredoc bodies included — to reach the first
266
+ * word cost up to ~1ms per call (a 7.8KB `cat <<HEREDOC …` classified on the word
267
+ * `cat`). Tokenize only this much of the head instead: enough for the executable
268
+ * plus a subcommand and its flags, never a heredoc tail (#1830, ~3.3x faster).
269
+ */
270
+ const CLASSIFY_HEAD_LIMIT = 200;
271
+ /**
272
+ * Tokens of the first simple command only, tokenizing just the head of the
273
+ * unwrapped string. Mirrors {@link tokenizeBash}'s first-segment result for
274
+ * short commands but skips the cost of tokenizing the whole command; the full
275
+ * multi-segment tokenizer stays available for callers that need every segment.
276
+ */
277
+ function firstSimpleCommandTokens(command) {
278
+ const unwrapped = unwrapCommand(command);
279
+ const head = unwrapped.length > CLASSIFY_HEAD_LIMIT ? unwrapped.slice(0, CLASSIFY_HEAD_LIMIT) : unwrapped;
280
+ const firstSegment = splitOnOperators(head)[0] ?? '';
281
+ if (!firstSegment)
282
+ return [];
283
+ try {
284
+ return shlexSplit(firstSegment);
285
+ }
286
+ catch {
287
+ // The head cut a quote mid-string (e.g. a long quoted flag value like
288
+ // `git -c http.extraheader="Authorization: Bearer …" fetch`), so shlex threw
289
+ // on the unbalanced quote. A whitespace split of the truncated head would
290
+ // mis-read the value as the subcommand, so re-tokenize the FULL first
291
+ // segment instead — only the rare throw path pays that cost.
292
+ const fullFirst = splitOnOperators(unwrapped)[0] ?? firstSegment;
293
+ try {
294
+ return shlexSplit(fullFirst);
295
+ }
296
+ catch {
297
+ // Genuinely unbalanced quoting even in the full segment — a whitespace
298
+ // split of the full segment still yields the leading executable.
299
+ return fullFirst.split(/\s+/).filter(Boolean);
300
+ }
301
+ }
302
+ }
250
303
  /**
251
304
  * Classify the first simple command in a Bash string. Returns coarse metadata
252
305
  * (tool name, category, subcommand, human action) used for summaries and
253
306
  * activity logging. Unknown executables fall back to `other`.
254
307
  */
255
308
  export function classifyBashCommand(command) {
256
- const simpleCommands = tokenizeBash(command);
257
- if (!simpleCommands.length) {
258
- return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
259
- }
260
- const tokens = simpleCommands[0];
309
+ const tokens = firstSimpleCommandTokens(command);
261
310
  if (!tokens.length) {
262
311
  return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
263
312
  }
264
313
  const first = tokens[0];
265
- const baseRaw = first.replace(/^[./]+/, '').toLowerCase();
314
+ // Reduce a path executable to its basename so `~/.agents/skills/linear/scripts/linear`,
315
+ // `/usr/bin/git`, and `./tool` all resolve by tool name, not the full path (#1830).
316
+ const baseRaw = first.replace(/^.*\//, '').toLowerCase();
266
317
  const base = baseRaw.endsWith('.exe') ? baseRaw.slice(0, -4) : baseRaw;
267
318
  const canonical = ALIAS_MAP.get(base);
268
319
  const info = canonical ? TOOL_REGISTRY[canonical] : undefined;
@@ -8,10 +8,11 @@
8
8
  */
9
9
  import Database from '../sqlite.js';
10
10
  import type { SessionAgentId, SessionMeta } from './types.js';
11
+ import { type IndexedToolCall } from './tool-calls.js';
11
12
  /** Current schema version; bumped when migrations are added. Exported so tests
12
13
  * assert against the constant instead of hardcoding a number that every bump
13
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
14
- export declare const SCHEMA_VERSION = 24;
15
+ export declare const SCHEMA_VERSION = 30;
15
16
  /** Raw row shape returned from the sessions table. */
16
17
  export interface SessionRow {
17
18
  id: string;
@@ -91,6 +92,8 @@ export interface QueryOptions {
91
92
  * with NULLs sorted last so unpriced rows never crowd out real data.
92
93
  */
93
94
  sortBy?: 'timestamp' | 'cost' | 'duration';
95
+ /** Internal warm-cache path; callers must validate the small final result set. */
96
+ skipExistenceCheck?: boolean;
94
97
  /**
95
98
  * Only sessions that invoked this skill (#12), joined against
96
99
  * session_resource_usage.kind='skill'. Matches either the full stored name
@@ -204,6 +207,9 @@ export declare function upsertSessionsBatch(entries: Array<{
204
207
  scan?: ScanStamp;
205
208
  parserState?: string;
206
209
  contentText?: string;
210
+ toolCalls?: IndexedToolCall[];
211
+ toolScan?: ScanStamp;
212
+ toolIndexMode?: 'replace' | 'append';
207
213
  }>): void;
208
214
  /**
209
215
  * Sync labels for a set of sessions. For each id in the map, if the stored