@phnx-labs/agents-cli 1.22.26 → 1.22.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  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 +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import { spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { getCliLaunch } from "../cli-entry.js";
6
+ export function extractTokenCount(stdout, stderr) {
7
+ const matches = [
8
+ ...`${stdout}\n${stderr}`.matchAll(/(?:tokens used|total tokens|token usage)\s*[:=]?\s*([\d,]+)/gi),
9
+ ];
10
+ const raw = matches.at(-1)?.[1];
11
+ if (!raw)
12
+ return undefined;
13
+ const tokens = Number(raw.replaceAll(",", ""));
14
+ return Number.isSafeInteger(tokens) && tokens >= 0 ? tokens : undefined;
15
+ }
16
+ export async function executeCellViaAgentsRun(request) {
17
+ const args = [
18
+ "run",
19
+ request.agent,
20
+ request.prompt,
21
+ "--cwd",
22
+ request.cwd,
23
+ "--headless",
24
+ "--no-tmux",
25
+ ];
26
+ if (request.model)
27
+ args.push("--model", request.model);
28
+ const launch = getCliLaunch(args);
29
+ return await new Promise((resolve, reject) => {
30
+ const child = spawn(launch.command, launch.args, {
31
+ cwd: request.cwd,
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ });
34
+ let stdout = "";
35
+ let stderr = "";
36
+ child.stdout.on("data", (chunk) => {
37
+ stdout += chunk.toString();
38
+ });
39
+ child.stderr.on("data", (chunk) => {
40
+ stderr += chunk.toString();
41
+ });
42
+ child.on("error", reject);
43
+ child.on("close", (code) => resolve({
44
+ exit: code,
45
+ stdout,
46
+ stderr,
47
+ tokens: extractTokenCount(stdout, stderr),
48
+ }));
49
+ });
50
+ }
51
+ function copyFixture(task, cellIndex, runRoot) {
52
+ const cwd = path.join(runRoot, `cell-${cellIndex}`);
53
+ fs.mkdirSync(cwd, { recursive: true });
54
+ if (fs.existsSync(task.fixtureDir))
55
+ fs.cpSync(task.fixtureDir, cwd, { recursive: true });
56
+ return cwd;
57
+ }
58
+ export async function runCells(options) {
59
+ if (!Number.isInteger(options.concurrency) || options.concurrency < 1)
60
+ throw new Error("concurrency must be a positive integer");
61
+ if (options.cells.length === 0)
62
+ throw new Error("at least one bench cell is required");
63
+ const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}`;
64
+ const startedAt = new Date();
65
+ const runRoot = fs.mkdtempSync(path.join(options.tempRoot ?? os.tmpdir(), `agents-bench-${runId}-`));
66
+ const results = new Array(options.cells.length);
67
+ const execute = options.execute ?? executeCellViaAgentsRun;
68
+ let next = 0;
69
+ const worker = async () => {
70
+ for (;;) {
71
+ const index = next++;
72
+ if (index >= options.cells.length)
73
+ return;
74
+ const cell = options.cells[index];
75
+ const cwd = options.task
76
+ ? copyFixture(options.task, index, runRoot)
77
+ : path.join(runRoot, `cell-${index}`);
78
+ fs.mkdirSync(cwd, { recursive: true });
79
+ const started = performance.now();
80
+ try {
81
+ const output = await execute({ ...cell, prompt: options.prompt, cwd });
82
+ results[index] = {
83
+ ...cell,
84
+ ...output,
85
+ wall_ms: Math.round(performance.now() - started),
86
+ status: output.exit === 0 ? "passed" : "failed",
87
+ };
88
+ }
89
+ catch (error) {
90
+ results[index] = {
91
+ ...cell,
92
+ exit: null,
93
+ stdout: "",
94
+ stderr: error instanceof Error ? error.message : String(error),
95
+ wall_ms: Math.round(performance.now() - started),
96
+ status: "failed",
97
+ };
98
+ }
99
+ }
100
+ };
101
+ await Promise.all(Array.from({ length: Math.min(options.concurrency, options.cells.length) }, worker));
102
+ return {
103
+ schema_version: 1,
104
+ run_id: runId,
105
+ ...(options.task ? { task_id: options.task.id } : {}),
106
+ prompt: options.prompt,
107
+ started_at: startedAt.toISOString(),
108
+ finished_at: new Date().toISOString(),
109
+ cells: results,
110
+ };
111
+ }
@@ -0,0 +1,5 @@
1
+ import type { BenchRunResult, BenchTask } from "./types.js";
2
+ export declare function parseTask(raw: unknown, sourcePath: string): BenchTask;
3
+ export declare function parseRunResult(raw: unknown): BenchRunResult;
4
+ export declare function validateRunId(runId: string): string;
5
+ export declare function loadTask(taskId: string, tasksRoot: string): BenchTask;
@@ -0,0 +1,91 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ function object(value, label) {
4
+ if (!value || typeof value !== "object" || Array.isArray(value))
5
+ throw new Error(`${label} must be an object`);
6
+ return value;
7
+ }
8
+ function string(value, label) {
9
+ if (typeof value !== "string" || value.trim() === "")
10
+ throw new Error(`${label} must be a non-empty string`);
11
+ return value;
12
+ }
13
+ export function parseTask(raw, sourcePath) {
14
+ const value = object(raw, "task.json");
15
+ const id = string(value.id, "task.id");
16
+ const prompt = string(value.prompt, "task.prompt");
17
+ if (!Array.isArray(value.pass) || value.pass.length === 0)
18
+ throw new Error("task.pass must be a non-empty array");
19
+ const pass = value.pass.map((item, index) => {
20
+ const criterion = object(item, `task.pass[${index}]`);
21
+ const type = string(criterion.type, `task.pass[${index}].type`);
22
+ if (type === "exit_zero")
23
+ return { type };
24
+ if (type === "file_exists")
25
+ return { type, path: string(criterion.path, `task.pass[${index}].path`) };
26
+ if (type === "stdout_contains")
27
+ return {
28
+ type,
29
+ value: string(criterion.value, `task.pass[${index}].value`),
30
+ };
31
+ if (type === "command_succeeds")
32
+ return {
33
+ type,
34
+ command: string(criterion.command, `task.pass[${index}].command`),
35
+ };
36
+ throw new Error(`task.pass[${index}].type is unsupported: ${type}`);
37
+ });
38
+ const taskDir = path.dirname(sourcePath);
39
+ return {
40
+ id,
41
+ prompt,
42
+ pass,
43
+ fixtureDir: path.join(taskDir, "fixture"),
44
+ sourcePath,
45
+ };
46
+ }
47
+ export function parseRunResult(raw) {
48
+ const value = object(raw, "bench result");
49
+ if (value.schema_version !== 1)
50
+ throw new Error("bench result schema_version must be 1");
51
+ string(value.run_id, "bench result run_id");
52
+ string(value.prompt, "bench result prompt");
53
+ string(value.started_at, "bench result started_at");
54
+ string(value.finished_at, "bench result finished_at");
55
+ if (value.task_id !== undefined)
56
+ string(value.task_id, "bench result task_id");
57
+ if (!Array.isArray(value.cells))
58
+ throw new Error("bench result cells must be an array");
59
+ value.cells.forEach((rawCell, index) => {
60
+ const cell = object(rawCell, `bench result cells[${index}]`);
61
+ string(cell.agent, `bench result cells[${index}].agent`);
62
+ if (cell.model !== undefined)
63
+ string(cell.model, `bench result cells[${index}].model`);
64
+ if (cell.status !== "passed" && cell.status !== "failed")
65
+ throw new Error(`bench result cells[${index}].status must be passed or failed`);
66
+ if (cell.exit !== null &&
67
+ (!Number.isInteger(cell.exit) || cell.exit < 0))
68
+ throw new Error(`bench result cells[${index}].exit must be a non-negative integer or null`);
69
+ if (typeof cell.wall_ms !== "number" ||
70
+ !Number.isFinite(cell.wall_ms) ||
71
+ cell.wall_ms < 0)
72
+ throw new Error(`bench result cells[${index}].wall_ms must be a non-negative number`);
73
+ if (cell.tokens !== undefined &&
74
+ (!Number.isSafeInteger(cell.tokens) || cell.tokens < 0))
75
+ throw new Error(`bench result cells[${index}].tokens must be a non-negative integer`);
76
+ if (typeof cell.stdout !== "string" || typeof cell.stderr !== "string")
77
+ throw new Error(`bench result cells[${index}] stdout and stderr must be strings`);
78
+ });
79
+ return value;
80
+ }
81
+ export function validateRunId(runId) {
82
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(runId))
83
+ throw new Error(`Invalid run id: ${runId}`);
84
+ return runId;
85
+ }
86
+ export function loadTask(taskId, tasksRoot) {
87
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(taskId))
88
+ throw new Error(`Invalid task id: ${taskId}`);
89
+ const sourcePath = path.join(tasksRoot, taskId, "task.json");
90
+ return parseTask(JSON.parse(fs.readFileSync(sourcePath, "utf8")), sourcePath);
91
+ }
@@ -0,0 +1,5 @@
1
+ import type { BenchRunResult } from "./types.js";
2
+ export declare function benchHistoryDir(): string;
3
+ export declare function saveRun(result: BenchRunResult, root?: string): string;
4
+ export declare function loadRun(runId: string, root?: string): BenchRunResult;
5
+ export declare function listRuns(root?: string): BenchRunResult[];
@@ -0,0 +1,32 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getUserAgentsDir } from "../state.js";
4
+ import { parseRunResult, validateRunId } from "./schema.js";
5
+ export function benchHistoryDir() {
6
+ return path.join(getUserAgentsDir(), ".history", "bench");
7
+ }
8
+ export function saveRun(result, root = benchHistoryDir()) {
9
+ parseRunResult(result);
10
+ validateRunId(result.run_id);
11
+ fs.mkdirSync(root, { recursive: true });
12
+ const target = path.join(root, `${result.run_id}.json`);
13
+ const temporary = `${target}.${process.pid}.tmp`;
14
+ fs.writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, {
15
+ mode: 0o600,
16
+ });
17
+ fs.renameSync(temporary, target);
18
+ return target;
19
+ }
20
+ export function loadRun(runId, root = benchHistoryDir()) {
21
+ validateRunId(runId);
22
+ return parseRunResult(JSON.parse(fs.readFileSync(path.join(root, `${runId}.json`), "utf8")));
23
+ }
24
+ export function listRuns(root = benchHistoryDir()) {
25
+ if (!fs.existsSync(root))
26
+ return [];
27
+ return fs
28
+ .readdirSync(root)
29
+ .filter((name) => name.endsWith(".json"))
30
+ .map((name) => parseRunResult(JSON.parse(fs.readFileSync(path.join(root, name), "utf8"))))
31
+ .sort((a, b) => b.started_at.localeCompare(a.started_at));
32
+ }
@@ -0,0 +1,40 @@
1
+ export type BenchPassCriterion = {
2
+ type: "file_exists";
3
+ path: string;
4
+ } | {
5
+ type: "exit_zero";
6
+ } | {
7
+ type: "stdout_contains";
8
+ value: string;
9
+ } | {
10
+ type: "command_succeeds";
11
+ command: string;
12
+ };
13
+ export interface BenchTask {
14
+ id: string;
15
+ prompt: string;
16
+ pass: BenchPassCriterion[];
17
+ fixtureDir: string;
18
+ sourcePath: string;
19
+ }
20
+ export interface BenchCell {
21
+ agent: string;
22
+ model?: string;
23
+ }
24
+ export interface BenchCellResult extends BenchCell {
25
+ status: "passed" | "failed";
26
+ exit: number | null;
27
+ wall_ms: number;
28
+ tokens?: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+ export interface BenchRunResult {
33
+ schema_version: 1;
34
+ run_id: string;
35
+ task_id?: string;
36
+ prompt: string;
37
+ started_at: string;
38
+ finished_at: string;
39
+ cells: BenchCellResult[];
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,5 @@
1
+ /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
2
+ export declare function isValidClaudeSetupToken(value: string): boolean;
1
3
  /** The per-account key an email maps to inside the `auth` bundle. */
2
4
  export declare function claudeAccountTokenKey(account: string): string;
3
5
  /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
@@ -11,6 +11,24 @@ import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/
11
11
  * multi-account fleet.
12
12
  */
13
13
  const AUTH_BUNDLE = 'auth';
14
+ /**
15
+ * A well-formed Claude OAuth setup-token: the `sk-ant-oat01-` prefix followed by
16
+ * token-safe characters only, on a single line. `claude setup-token` mints exactly
17
+ * this; nothing else is a token.
18
+ *
19
+ * The provisioning capture bug behind #1767 stored the raw `claude setup-token`
20
+ * TTY stream — the welcome banner, ANSI control sequences, box-drawing art, and the
21
+ * token buried inside — under a per-account key instead of the parsed value. That
22
+ * blob is not a token: injected as `Authorization: Bearer <blob>` it made Anthropic
23
+ * reject every request and the run crash. agents-cli only *consumes* these tokens
24
+ * (the mint itself is the Rush Cloud / mint-auth path), so this is the boundary
25
+ * where a corrupt bundle entry must be caught before it reaches the auth header.
26
+ */
27
+ const SETUP_TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]+$/;
28
+ /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
29
+ export function isValidClaudeSetupToken(value) {
30
+ return SETUP_TOKEN_RE.test(value);
31
+ }
14
32
  /** The per-account key an email maps to inside the `auth` bundle. */
15
33
  export function claudeAccountTokenKey(account) {
16
34
  const slug = account
@@ -55,7 +73,15 @@ export function resolveClaudeSetupToken(home) {
55
73
  return null;
56
74
  const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
57
75
  const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
58
- return v.length > 0 ? v : null;
76
+ if (v.length === 0)
77
+ return null;
78
+ // Reject a malformed stored value (e.g. a captured setup-token TTY blob, #1767)
79
+ // rather than let it become an `Authorization: Bearer <blob>` header that
80
+ // Anthropic rejects and crashes the run. A corrupt entry is treated as no
81
+ // usable file-based token, so the caller falls back to the normal login.
82
+ if (!isValidClaudeSetupToken(v))
83
+ return null;
84
+ return v;
59
85
  }
60
86
  catch {
61
87
  return null;
@@ -186,6 +186,8 @@ export interface CrabboxRunOptions extends CrabboxOptions {
186
186
  onData?: (chunk: string) => void;
187
187
  /** Force a full remote resync before running. */
188
188
  fullResync?: boolean;
189
+ /** Refresh an existing lease with this idle window before running. */
190
+ renewIdleTimeoutSecs?: number;
189
191
  }
190
192
  /**
191
193
  * Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
@@ -485,6 +485,8 @@ export function crabboxRun(slug, remoteCmd, opts = {}) {
485
485
  export function crabboxRunScript(slug, script, opts = {}) {
486
486
  findCrabbox();
487
487
  const args = ['run', '--id', slug, '--reclaim'];
488
+ if (opts.renewIdleTimeoutSecs !== undefined)
489
+ args.push('--idle-timeout', `${opts.renewIdleTimeoutSecs}s`);
488
490
  if (opts.fullResync)
489
491
  args.push('--full-resync');
490
492
  args.push('--script-stdin');
@@ -233,7 +233,9 @@ export function isExpiredPoolStray(box, opts) {
233
233
  return false;
234
234
  if (box.expiresAt === null || box.expiresAt > nowSecs)
235
235
  return false; // unexpired ⇒ reusable
236
- if (box.lastTouchedAt !== null && nowSecs - box.lastTouchedAt < graceSecs)
236
+ if (box.lastTouchedAt === null)
237
+ return false; // unknown age is never reap-safe
238
+ if (nowSecs - box.lastTouchedAt < graceSecs)
237
239
  return false; // maybe active
238
240
  return true;
239
241
  }
@@ -329,9 +331,13 @@ export async function leaseAndRun(opts) {
329
331
  let exitCode = null;
330
332
  let toreDown = false;
331
333
  try {
334
+ const renewIdleTimeoutSecs = reused && !opts.reuseBox && box.idleTimeoutSecs !== null
335
+ ? box.idleTimeoutSecs
336
+ : undefined;
332
337
  exitCode = await crabboxRunScript(box.slug, script, {
333
338
  secretsBundle: opts.secretsBundle,
334
339
  onData: opts.onData,
340
+ renewIdleTimeoutSecs,
335
341
  });
336
342
  }
337
343
  finally {
@@ -20,6 +20,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
20
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
21
  import { runCatchup } from './catchup.js';
22
22
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
23
+ import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from './routine-notify-owner.js';
23
24
  import { BrowserService } from './browser/service.js';
24
25
  import { BrowserIPCServer } from './browser/ipc.js';
25
26
  import { redactSecrets } from './redact.js';
@@ -516,6 +517,16 @@ export async function runDaemon() {
516
517
  notifyRoutineFinish(final);
517
518
  }
518
519
  catch { /* best-effort */ }
520
+ // RUSH-2288: a failed/timed-out routine also reaches the OWNER's phone
521
+ // (in-process owner channel stack), not just the local desktop. Green
522
+ // runs are silent — the builder returns early. Async + swallowed so a
523
+ // delivery hiccup never blocks the finish path.
524
+ void notifyOwnerRoutineFinish(final)
525
+ .then((r) => {
526
+ if (r.attempts.length && !r.delivered)
527
+ log('WARN', `Owner failure-notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
528
+ })
529
+ .catch(() => { });
519
530
  },
520
531
  });
521
532
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
@@ -531,6 +542,15 @@ export async function runDaemon() {
531
542
  notifyRoutineStartFailed(config, message);
532
543
  }
533
544
  catch { /* best-effort */ }
545
+ // RUSH-2288: the pre-spawn failure (e.g. auth_failed) is exactly the one the
546
+ // per-routine `agents notify` prompt can never send — its agent never ran —
547
+ // so the daemon reaches the owner directly.
548
+ void notifyOwnerRoutineStartFailed(config, message)
549
+ .then((r) => {
550
+ if (r.attempts.length && !r.delivered)
551
+ log('WARN', `Owner start-failure notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
552
+ })
553
+ .catch(() => { });
534
554
  }
535
555
  };
536
556
  let scheduler = null;
@@ -50,6 +50,8 @@ export interface SshHostKeyOptions {
50
50
  /** Managed known_hosts path override (tests). Defaults to the CLI-managed store. */
51
51
  knownHostsFile?: string;
52
52
  }
53
+ /** OpenSSH argv that makes an explicit device key authoritative. */
54
+ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
53
55
  /**
54
56
  * Build the argv (after the `ssh` program name) and the environment overlay
55
57
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
@@ -82,6 +82,12 @@ export function wrapRemoteCommand(device, cmd) {
82
82
  }
83
83
  return joined;
84
84
  }
85
+ /** OpenSSH argv that makes an explicit device key authoritative. */
86
+ export function deviceIdentityArgs(device) {
87
+ return device.auth?.method === 'key' && device.auth.identityFile
88
+ ? ['-i', device.auth.identityFile, '-o', 'IdentitiesOnly=yes']
89
+ : [];
90
+ }
85
91
  /**
86
92
  * Build the argv (after the `ssh` program name) and the environment overlay
87
93
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
@@ -118,6 +124,7 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
118
124
  }
119
125
  else {
120
126
  args.push('-o', 'BatchMode=yes');
127
+ args.push(...deviceIdentityArgs(device));
121
128
  }
122
129
  // An interactive login (no remote command) needs a real tty.
123
130
  if (!remote)
@@ -2,6 +2,7 @@ import type { AgentId } from '../types.js';
2
2
  import type { DuplicateVersionHook } from '../hooks.js';
3
3
  import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
4
4
  import type { OwnerSinkStatus } from '../channels/owner-sink.js';
5
+ import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
5
6
  import type { SyncStatusRow, OrphanRow } from '../drift.js';
6
7
  import type { FetchStatusMarker } from '../auto-pull.js';
7
8
  import type { VersionResourceReport } from '../doctor-diff.js';
@@ -11,7 +12,7 @@ export type FindingSeverity = 'critical' | 'warning';
11
12
  * the JSON consumer group by kind. */
12
13
  /** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
13
14
  * below owns it, and a second copy in these comments is a fourth place to drift. */
14
- export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli", "owner-sink-unreachable"];
15
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
15
16
  /**
16
17
  * The severity each kind is emitted with - the SINGLE source of truth, read by
17
18
  * the builders below and asserted against both prose rubrics by
@@ -102,6 +103,8 @@ export interface LocalFindingInputs {
102
103
  platform: NodeJS.Platform;
103
104
  policy: string | null;
104
105
  };
106
+ /** Read-only Windows OpenSSH AuthorizedKeysFile/content/ACL audit. */
107
+ windowsSshEnrollment?: WindowsSshEnrollmentAudit | null;
105
108
  /** `<agent>@<version>` keys whose home is an isolated copy. Their findings are
106
109
  * never collapsed across versions: the agent-wide `agents doctor <agent> --fix`
107
110
  * sweep deliberately skips isolated copies, so a collapsed row would print a
@@ -18,7 +18,7 @@
18
18
  * with. Keep this list exhaustive; a kind missing from it is a doc that lies.
19
19
  * CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
20
20
  * unwired-hook (a hook on disk that settings.json never fires) ·
21
- * cli-missing · owner-sink-unreachable (the feed/notify owner lane
21
+ * cli-missing · ssh-key-enrollment · owner-sink-unreachable (the feed/notify owner lane
22
22
  * cannot reach the owner from this box).
23
23
  * WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
24
24
  * never-synced · stale · repo-behind · repo-drift · version-skew ·
@@ -40,6 +40,7 @@ import { blocksLocalScripts } from '../platform/winpath.js';
40
40
  import { loginHint } from '../signin-badge.js';
41
41
  import { CONFIG_ENV_ISOLATED_AGENTS } from '../shims.js';
42
42
  import { padToWidth, stringWidth } from '../session/width.js';
43
+ import { windowsSshEnrollmentProblem } from './windows-ssh-enrollment.js';
43
44
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
44
45
  /** Agents with NO per-version credential isolation: their login is shared across
45
46
  * every installed version, so a "log into THIS version" remediation would be a
@@ -97,6 +98,7 @@ export const ALL_FINDING_KINDS = [
97
98
  'rc-secret-export', // credential-shaped export in a shell rc file
98
99
  'env-secret-export', // the file-store master key live in THIS process's env
99
100
  'exec-policy', // Windows execution policy blocks agents.ps1
101
+ 'ssh-key-enrollment', // Windows OpenSSH public-key path/content/ACL is invalid
100
102
  'stale-cli',
101
103
  'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
102
104
  ];
@@ -141,6 +143,7 @@ export const FINDING_SEVERITY = {
141
143
  'rc-secret-export': 'warning',
142
144
  'env-secret-export': 'warning',
143
145
  'exec-policy': 'warning',
146
+ 'ssh-key-enrollment': 'critical',
144
147
  'stale-cli': 'warning',
145
148
  };
146
149
  function agentName(agent) {
@@ -254,6 +257,8 @@ export function remediationFor(finding) {
254
257
  return 'unset at the source, then restart every process that inherited it (shells, editor, tmux, agents daemon)';
255
258
  case 'exec-policy':
256
259
  return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
260
+ case 'ssh-key-enrollment':
261
+ return 'repair the reported AuthorizedKeysFile, then rerun agents doctor';
257
262
  case 'stale-cli':
258
263
  return 'upgrade';
259
264
  case 'owner-sink-unreachable':
@@ -493,6 +498,15 @@ export function buildLocalFindings(input) {
493
498
  const policyFinding = execPolicyFinding(device, input.execPolicy);
494
499
  if (policyFinding)
495
500
  out.push(policyFinding);
501
+ if (input.windowsSshEnrollment) {
502
+ const problem = windowsSshEnrollmentProblem(input.windowsSshEnrollment);
503
+ if (problem) {
504
+ out.push(finding({
505
+ severity: FINDING_SEVERITY['ssh-key-enrollment'], kind: 'ssh-key-enrollment', device,
506
+ message: problem,
507
+ }));
508
+ }
509
+ }
496
510
  // Per-version sign-in → logged-out (critical, provable) / logout-unprovable
497
511
  // (warning). Signed-in versions produce no finding — the accounts line shows
498
512
  // them. Agents that can't be inspected never yield a logout finding.
@@ -0,0 +1,97 @@
1
+ import type { AgentId } from '../types.js';
2
+ import { type UsageSnapshot } from '../usage.js';
3
+ /** An account's usage headroom rolled into one glanceable summary. */
4
+ export interface QuotaSummary {
5
+ /**
6
+ * `available` / `rate_limited` from the live usage windows, or `null` when
7
+ * there is no snapshot at all (no data yet, or the agent has no usage source).
8
+ */
9
+ status: 'available' | 'rate_limited' | null;
10
+ /** Max utilization across blocking windows (0-100, rounded), or null when unknown. */
11
+ usedPercent: number | null;
12
+ /** True when the snapshot is a cached `last_seen`, not a fresh `live` read. */
13
+ stale: boolean;
14
+ }
15
+ /** One installed (agent, version) on a host, fully resolved. */
16
+ export interface HarnessRow {
17
+ agent: AgentId;
18
+ version: string;
19
+ /** Account display label (email / id), or null when signed out or unidentifiable. */
20
+ account: string | null;
21
+ signedIn: boolean;
22
+ quota: QuotaSummary;
23
+ /** signed in AND not rate-limited — usable for a run right now. */
24
+ ready: boolean;
25
+ /** When `!ready`, a short reason ("signed out" / "rate-limited"). */
26
+ reason?: string;
27
+ }
28
+ /** One host's rows, or the reason it produced none. */
29
+ export interface HostHarnessResult {
30
+ host: string;
31
+ rows: HarnessRow[];
32
+ /** Set when the host failed to probe (offline / error). */
33
+ error?: string;
34
+ /** Set when the host was skipped (control / offline). */
35
+ skipped?: string;
36
+ }
37
+ /** An account collapsed across the installs on one host that share it. */
38
+ export interface AccountGroup {
39
+ /** Account display label, or null for the signed-out bucket. */
40
+ account: string | null;
41
+ /** Distinct agent ids using this account, sorted. */
42
+ agents: AgentId[];
43
+ /** How many (agent, version) installs share this account. */
44
+ installs: number;
45
+ /** True when at least one install under this account is signed in. */
46
+ signedIn: boolean;
47
+ /** Representative quota — an account maps to one provider identity, so its usage is shared. */
48
+ quota: QuotaSummary;
49
+ ready: boolean;
50
+ reason?: string;
51
+ }
52
+ /**
53
+ * Roll one usage snapshot into a {@link QuotaSummary}. Mirrors the blocking-window
54
+ * selection of {@link deriveUsageStatusFromSnapshot} (the model-specific
55
+ * `sonnet_week` sub-limit is excluded so hitting it doesn't read as a throttled
56
+ * account) and takes the highest utilization across those windows. Pure.
57
+ */
58
+ export declare function summarizeQuota(snapshot: UsageSnapshot | null | undefined): QuotaSummary;
59
+ /**
60
+ * "Ready" = signed in AND not rate-limited. A missing quota snapshot does NOT
61
+ * block readiness: the account is signed in and usable, we just have no live
62
+ * utilization to show. Pure.
63
+ */
64
+ export declare function computeReady(signedIn: boolean, quota: QuotaSummary): {
65
+ ready: boolean;
66
+ reason?: string;
67
+ };
68
+ /**
69
+ * Enumerate every installed (agent, version) on THIS host, resolve account +
70
+ * sign-in + quota + ready, and return the rows. Signed-out installs are kept
71
+ * (they are the actionable ones), unlike the auth-health probe which drops
72
+ * `unconfigured`. Quota is cache-only unless `refresh` is set.
73
+ */
74
+ export declare function collectLocalHarnessInventory(opts?: {
75
+ agents?: readonly AgentId[];
76
+ refresh?: boolean;
77
+ }): Promise<HarnessRow[]>;
78
+ /**
79
+ * Collapse a host's rows into one {@link AccountGroup} per distinct account (the
80
+ * signed-out installs fall into the `null` bucket). Because an account label maps
81
+ * to a single provider identity, every install sharing it shares one quota, so
82
+ * the group's quota is taken from the first row carrying real data (preferring a
83
+ * `rate_limited` row so a throttle is never hidden). Pure.
84
+ */
85
+ export declare function groupByAccount(rows: HarnessRow[]): AccountGroup[];
86
+ /** Short human quota cell: "12%", "limited", or "—" when no data. */
87
+ export declare function formatQuota(quota: QuotaSummary): string;
88
+ /**
89
+ * Render the device × harness table: one block per device, one row per installed
90
+ * (agent, version) with account / signed-in / quota / ready. Pure.
91
+ */
92
+ export declare function renderHarnessMatrix(results: HostHarnessResult[]): string[];
93
+ /**
94
+ * Render the device × account table: one block per device, one row per distinct
95
+ * account (installs sharing it collapsed), showing which harnesses use it. Pure.
96
+ */
97
+ export declare function renderAccountsMatrix(results: HostHarnessResult[]): string[];
@@ -26,6 +26,8 @@ export interface DeviceAddress {
26
26
  /** Authentication settings for a device. */
27
27
  export interface DeviceAuth {
28
28
  method: DeviceAuthMethod;
29
+ /** Explicit private-key path passed to OpenSSH for key authentication. */
30
+ identityFile?: string;
29
31
  /** Secrets bundle holding the password (when method === 'password'). */
30
32
  bundle?: string;
31
33
  /** Key within the bundle whose value is the password. Defaults to 'password'. */