@phnx-labs/agents-cli 1.22.25 → 1.22.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -18,7 +18,8 @@
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.
21
+ * cli-missing · ssh-key-enrollment · owner-sink-unreachable (the feed/notify owner lane
22
+ * cannot reach the owner from this box).
22
23
  * WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
23
24
  * never-synced · stale · repo-behind · repo-drift · version-skew ·
24
25
  * fleet-resource-gap · orphan · duplicate-hook ·
@@ -39,6 +40,7 @@ import { blocksLocalScripts } from '../platform/winpath.js';
39
40
  import { loginHint } from '../signin-badge.js';
40
41
  import { CONFIG_ENV_ISOLATED_AGENTS } from '../shims.js';
41
42
  import { padToWidth, stringWidth } from '../session/width.js';
43
+ import { windowsSshEnrollmentProblem } from './windows-ssh-enrollment.js';
42
44
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
43
45
  /** Agents with NO per-version credential isolation: their login is shared across
44
46
  * every installed version, so a "log into THIS version" remediation would be a
@@ -96,7 +98,9 @@ export const ALL_FINDING_KINDS = [
96
98
  'rc-secret-export', // credential-shaped export in a shell rc file
97
99
  'env-secret-export', // the file-store master key live in THIS process's env
98
100
  'exec-policy', // Windows execution policy blocks agents.ps1
101
+ 'ssh-key-enrollment', // Windows OpenSSH public-key path/content/ACL is invalid
99
102
  'stale-cli',
103
+ 'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
100
104
  ];
101
105
  /**
102
106
  * The severity each kind is emitted with - the SINGLE source of truth, read by
@@ -116,6 +120,9 @@ export const FINDING_SEVERITY = {
116
120
  'missing-plugin': 'critical',
117
121
  'unwired-hook': 'critical',
118
122
  'cli-missing': 'critical',
123
+ // A factory that cannot escalate a blocked agent to the owner is not healthy,
124
+ // and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
125
+ 'owner-sink-unreachable': 'critical',
119
126
  // Everything else is resolvable by a routine sync/cleanup and does not block
120
127
  // the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
121
128
  // here: both are stale-sync states that one `agents sync` resolves.
@@ -136,6 +143,7 @@ export const FINDING_SEVERITY = {
136
143
  'rc-secret-export': 'warning',
137
144
  'env-secret-export': 'warning',
138
145
  'exec-policy': 'warning',
146
+ 'ssh-key-enrollment': 'critical',
139
147
  'stale-cli': 'warning',
140
148
  };
141
149
  function agentName(agent) {
@@ -249,8 +257,17 @@ export function remediationFor(finding) {
249
257
  return 'unset at the source, then restart every process that inherited it (shells, editor, tmux, agents daemon)';
250
258
  case 'exec-policy':
251
259
  return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
260
+ case 'ssh-key-enrollment':
261
+ return 'repair the reported AuthorizedKeysFile, then rerun agents doctor';
252
262
  case 'stale-cli':
253
263
  return 'upgrade';
264
+ case 'owner-sink-unreachable':
265
+ // The lane delivers over the rush-backed owner channel, which needs rush on
266
+ // PATH AND a usable session in THIS context. Non-interactive shells miss a
267
+ // ~/.zshrc export (RUSH-2258), and the session is keychain-bound, not in
268
+ // ~/.rush/user.yaml (RUSH-2262) — so `rush login` here, or the Rush App for
269
+ // a GUI keychain, is what makes it reachable.
270
+ return "put rush on PATH for non-interactive shells (~/.zshenv) and run 'rush login'";
254
271
  }
255
272
  }
256
273
  function finding(f) {
@@ -293,6 +310,22 @@ export function buildLocalFindings(input) {
293
310
  message: `${agentName(agent)} binary not found`,
294
311
  }));
295
312
  }
313
+ // owner-sink-unreachable — the feed/notify owner-delivery lane can't reach the
314
+ // owner from this box (RUSH-2262). Only when owner delivery is CONFIGURED for the
315
+ // fleet; an un-opted-in box is not broken. The message names the concrete reason.
316
+ const sink = input.ownerSink;
317
+ if (sink?.configured && !sink.reachable) {
318
+ const chan = sink.channel ?? 'owner';
319
+ const why = sink.reason === 'rush-not-on-path'
320
+ ? `rush CLI not on this box's PATH`
321
+ : sink.reason === 'rush-signed-out'
322
+ ? 'rush has no usable session here'
323
+ : 'transport unreachable';
324
+ out.push(finding({
325
+ severity: FINDING_SEVERITY['owner-sink-unreachable'], kind: 'owner-sink-unreachable', device,
326
+ message: `${chan} → owner unreachable: ${why}`,
327
+ }));
328
+ }
296
329
  // Per-version resource reports → missing hook/plugin (critical), unwired hook
297
330
  // (critical), other missing kinds (warning), content drift (warning).
298
331
  for (const report of input.reports) {
@@ -465,6 +498,15 @@ export function buildLocalFindings(input) {
465
498
  const policyFinding = execPolicyFinding(device, input.execPolicy);
466
499
  if (policyFinding)
467
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
+ }
468
510
  // Per-version sign-in → logged-out (critical, provable) / logout-unprovable
469
511
  // (warning). Signed-in versions produce no finding — the accounts line shows
470
512
  // them. Agents that can't be inspected never yield a logout finding.
@@ -782,6 +824,10 @@ function subjectLabel(f) {
782
824
  return f.version ? `${f.agent} @${f.version}` : f.agent;
783
825
  }
784
826
  function critLabel(f) {
827
+ // Machine-level criticals with no agent get a category subject so the left
828
+ // column is not blank; the owner-sink row reads `owner …`, not an empty label.
829
+ if (f.kind === 'owner-sink-unreachable')
830
+ return { left: 'owner', account: '', message: f.message };
785
831
  return {
786
832
  left: subjectLabel(f),
787
833
  account: f.account ?? '',
@@ -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'. */
@@ -15,6 +15,9 @@ function renderHost(device) {
15
15
  const lines = [`Host ${device.name}`, ` HostName ${hostName}`];
16
16
  if (device.user)
17
17
  lines.push(` User ${device.user}`);
18
+ if (device.auth.method === 'key' && device.auth.identityFile) {
19
+ lines.push(` IdentityFile ${device.auth.identityFile}`, ' IdentitiesOnly yes');
20
+ }
18
21
  return lines.join('\n');
19
22
  }
20
23
  /**
@@ -0,0 +1,20 @@
1
+ export interface WindowsSshEnrollment {
2
+ administrator: boolean;
3
+ expectedPath: string;
4
+ configuredPaths: string[];
5
+ fileExists: boolean;
6
+ hasPublicKey: boolean;
7
+ owner: string | null;
8
+ systemFullControl: boolean;
9
+ administratorsFullControl: boolean;
10
+ unexpectedAclPrincipals: string[];
11
+ }
12
+ export type WindowsSshEnrollmentAudit = {
13
+ status: WindowsSshEnrollment;
14
+ } | {
15
+ error: string;
16
+ };
17
+ export declare function diagnoseWindowsSshFailure(stderr: string, timedOut: boolean): string;
18
+ export declare function parseWindowsSshEnrollment(stdout: string): WindowsSshEnrollment;
19
+ export declare function auditWindowsSshEnrollment(platform?: NodeJS.Platform): WindowsSshEnrollmentAudit | null;
20
+ export declare function windowsSshEnrollmentProblem(audit: WindowsSshEnrollmentAudit): string | null;
@@ -0,0 +1,98 @@
1
+ import { spawnSync } from 'child_process';
2
+ export function diagnoseWindowsSshFailure(stderr, timedOut) {
3
+ if (timedOut || /connection timed out|operation timed out/i.test(stderr)) {
4
+ return 'Windows SSH unreachable: port 22 did not answer before the timeout';
5
+ }
6
+ if (/host key verification failed|remote host identification has changed/i.test(stderr)) {
7
+ return 'Windows OpenSSH host-key verification failed; verify and re-pin the device host key';
8
+ }
9
+ if (/permission denied.*publickey|permission denied \(publickey/i.test(stderr)) {
10
+ return 'port 22 and OpenSSH are reachable, but the public key was rejected; recover through the Windows console or password-auth profile, then run agents doctor on that box to inspect its effective AuthorizedKeysFile and ACL';
11
+ }
12
+ if (/connection refused/i.test(stderr)) {
13
+ return 'Windows host is reachable but OpenSSH is not listening on port 22';
14
+ }
15
+ return stderr.trim() || 'Windows SSH probe failed';
16
+ }
17
+ const AUDIT_SCRIPT = String.raw `
18
+ $ErrorActionPreference = 'Stop'
19
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
20
+ $administratorSid = 'S-1-5-32-544'
21
+ $administrator = @($identity.Groups | Where-Object { $_.Value -eq $administratorSid }).Count -gt 0
22
+ $expected = if ($administrator) { Join-Path $env:ProgramData 'ssh\administrators_authorized_keys' } else { Join-Path $env:USERPROFILE '.ssh\authorized_keys' }
23
+ $sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'
24
+ $effective = (& $sshd -T -C "user=$env:USERNAME,host=$env:COMPUTERNAME,addr=127.0.0.1" 2>$null | Select-String '^authorizedkeysfile ' | Select-Object -First 1).Line
25
+ $configured = if ($effective) { @($effective.Substring('authorizedkeysfile '.Length).Split(' ', [StringSplitOptions]::RemoveEmptyEntries)) } else { @() }
26
+ $exists = Test-Path -LiteralPath $expected -PathType Leaf
27
+ $acl = if ($exists) { Get-Acl -LiteralPath $expected } else { $null }
28
+ $rules = if ($acl) { @($acl.Access | Where-Object AccessControlType -eq Allow) } else { @() }
29
+ $full = [Security.AccessControl.FileSystemRights]::FullControl
30
+ $system = @($rules | Where-Object { $_.IdentityReference.Value -in @('NT AUTHORITY\SYSTEM', 'S-1-5-18') -and ($_.FileSystemRights -band $full) -eq $full }).Count -gt 0
31
+ $admins = @($rules | Where-Object { $_.IdentityReference.Value -in @('BUILTIN\Administrators', 'S-1-5-32-544') -and ($_.FileSystemRights -band $full) -eq $full }).Count -gt 0
32
+ $allowed = @('NT AUTHORITY\SYSTEM', 'S-1-5-18', 'BUILTIN\Administrators', 'S-1-5-32-544')
33
+ $unexpected = if ($administrator) { @($rules | ForEach-Object { $_.IdentityReference.Value } | Where-Object { $_ -notin $allowed } | Sort-Object -Unique) } else { @() }
34
+ $hasKey = $exists -and [bool](Select-String -LiteralPath $expected -Pattern '^\s*(ssh-(rsa|ed25519)|ecdsa-sha2-)' -Quiet)
35
+ [ordered]@{ administrator=$administrator; expectedPath=$expected; configuredPaths=$configured; fileExists=$exists; hasPublicKey=$hasKey; owner=if($acl){$acl.Owner}else{$null}; systemFullControl=$system; administratorsFullControl=$admins; unexpectedAclPrincipals=$unexpected } | ConvertTo-Json -Compress
36
+ `;
37
+ export function parseWindowsSshEnrollment(stdout) {
38
+ const raw = JSON.parse(stdout.trim());
39
+ const strings = (value) => Array.isArray(value)
40
+ ? value.filter((item) => typeof item === 'string')
41
+ : typeof value === 'string' ? [value] : [];
42
+ if (typeof raw.administrator !== 'boolean' || typeof raw.expectedPath !== 'string') {
43
+ throw new Error('Windows SSH enrollment audit returned an invalid payload');
44
+ }
45
+ return {
46
+ administrator: raw.administrator,
47
+ expectedPath: raw.expectedPath,
48
+ configuredPaths: strings(raw.configuredPaths),
49
+ fileExists: raw.fileExists === true,
50
+ hasPublicKey: raw.hasPublicKey === true,
51
+ owner: typeof raw.owner === 'string' ? raw.owner : null,
52
+ systemFullControl: raw.systemFullControl === true,
53
+ administratorsFullControl: raw.administratorsFullControl === true,
54
+ unexpectedAclPrincipals: strings(raw.unexpectedAclPrincipals),
55
+ };
56
+ }
57
+ export function auditWindowsSshEnrollment(platform = process.platform) {
58
+ if (platform !== 'win32')
59
+ return null;
60
+ const encoded = Buffer.from(AUDIT_SCRIPT, 'utf16le').toString('base64');
61
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], {
62
+ encoding: 'utf8',
63
+ windowsHide: true,
64
+ timeout: 10_000,
65
+ });
66
+ if (result.status !== 0)
67
+ return { error: (result.stderr || 'audit command failed').trim() };
68
+ try {
69
+ return { status: parseWindowsSshEnrollment(result.stdout) };
70
+ }
71
+ catch (error) {
72
+ return { error: error instanceof Error ? error.message : String(error) };
73
+ }
74
+ }
75
+ export function windowsSshEnrollmentProblem(audit) {
76
+ if ('error' in audit)
77
+ return `Windows OpenSSH enrollment audit failed: ${audit.error}`;
78
+ const status = audit.status;
79
+ const normalize = (path) => path.replace(/\//g, '\\').toLowerCase();
80
+ const expected = normalize(status.expectedPath);
81
+ const effective = status.configuredPaths.map((path) => normalize(path)
82
+ .replace('__programdata__', normalize(process.env.ProgramData ?? 'C:\\ProgramData'))
83
+ .replace(/^\.ssh\\/, `${normalize(process.env.USERPROFILE ?? '')}\\.ssh\\`));
84
+ if (effective.length > 0 && !effective.includes(expected)) {
85
+ return `OpenSSH AuthorizedKeysFile resolves to ${status.configuredPaths.join(', ')}, not ${status.expectedPath}`;
86
+ }
87
+ if (!status.fileExists)
88
+ return `SSH public-key file missing: ${status.expectedPath}`;
89
+ if (!status.hasPublicKey)
90
+ return `no public key enrolled in ${status.expectedPath}`;
91
+ if (status.administrator && (!status.systemFullControl || !status.administratorsFullControl)) {
92
+ return `${status.expectedPath} ACL must grant FullControl to SYSTEM and Administrators`;
93
+ }
94
+ if (status.administrator && status.unexpectedAclPrincipals.length > 0) {
95
+ return `${status.expectedPath} ACL grants access to unexpected principals: ${status.unexpectedAclPrincipals.join(', ')}`;
96
+ }
97
+ return null;
98
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Benchmark for the agent-execution hot path: env construction (buildExecEnv),
3
+ * argv assembly (buildExecCommand), and process spawn (execAgent -> spawnAgent).
4
+ *
5
+ * No mocking. Runs against this machine's REAL ~/.agents layout — the actual
6
+ * agents.yaml (state.ts:1124 readMeta), the actual installed version homes
7
+ * under ~/.agents/.history/versions/<agent>/ (versions.ts:1284
8
+ * listInstalledVersions), and, for the spawn group, the actual installed
9
+ * `claude` binary. `process.cwd()` during a real `vitest bench` run is this
10
+ * package's directory inside whatever checkout invoked it (typically several
11
+ * levels deep under a git worktree), so the project-version directory walk in
12
+ * versions.ts:2287 getProjectVersion runs its REAL depth here, not a synthetic
13
+ * shallow stub.
14
+ *
15
+ * The dominant, measured cost driver in buildExecEnv turned out to be neither
16
+ * of the two caches below on their own -- it's claude-account-token.ts:51
17
+ * resolveClaudeSetupToken(), called from exec.ts:424 for EVERY resolved claude
18
+ * version (pinned or auto-resolved) whose version home has a signed-in
19
+ * account. It is entirely uncached: each call re-derives an AES key via
20
+ * `scryptSync` (secrets/filestore.ts:208-210, invoked from
21
+ * decryptForFallback at secrets/filestore.ts:230-240) to decrypt the
22
+ * file-backed `auth` secrets bundle. Measured standalone (a one-off
23
+ * `performance.now()` probe against the real bundle, not part of this
24
+ * benchmark file): ~150-170ms per call, first call and every call after —
25
+ * scrypt is deliberately memory-hard/slow and nothing memoizes the derived
26
+ * key or the decrypted token across calls. A version home with NO signed-in
27
+ * account short-circuits at claude-account-token.ts:56-57
28
+ * (readClaudeAccountEmail returns null) before ever reaching the decrypt, so
29
+ * the SAME code path costs ~0.07ms or ~150ms purely depending on whether that
30
+ * specific version is logged in — that split, not warm/cold cache state, is
31
+ * the headline finding, so it is what this file isolates directly with two
32
+ * explicit version pins discovered from the real ~/.agents/.history/versions/
33
+ * layout: `loggedInClaudeVersion` and `loggedOutClaudeVersion`.
34
+ *
35
+ * Two cache regimes are ALSO benchmarked, because they cost meaningfully
36
+ * different amounts on top of the above:
37
+ *
38
+ * - WARM: state.ts:1124 readMeta()'s mtime-keyed cache and actor.ts:163
39
+ * resolveActor()'s process-lifetime cache are both hot. This is the
40
+ * steady state inside one long-lived orchestrator process that calls
41
+ * buildExecEnv many times (loop.ts:214, teams, runner.ts:720).
42
+ * - COLD: both caches invalidated before every sample, matching the FIRST
43
+ * buildExecEnv call in a freshly spawned `agents run` process -- the
44
+ * common case, since every real CLI invocation is its own process.
45
+ * Measured here it costs about the same as warm (readMeta's re-parse of a
46
+ * ~5KB agents.yaml and a fresh actor resolve are both sub-millisecond) --
47
+ * NOT because the invalidation is a no-op, but because this shell's
48
+ * process.env already carries `AGENTS_ACTOR=UNRESOLVED@zion` (inherited
49
+ * from the agent harness that launched this session), so actor.ts:149
50
+ * `inheritedActor(env)` returns immediately and computeActor() never
51
+ * reaches the SSH branch. A genuinely fresh interactive terminal (no
52
+ * inherited AGENTS_ACTOR) would instead hit actor.ts:152-154 and, on an
53
+ * SSH-connected box (SSH_CONNECTION set), pay actor.ts:62 tailscaleWhois()
54
+ * — a real `spawnSync('tailscale', ['whois', ...])` subprocess capped at
55
+ * 2s (actor.ts:53 WHOIS_TIMEOUT_MS) — on that literal first call. That
56
+ * cost is real but environment-dependent and not reproduced by this bench
57
+ * run; it is called out here rather than silently assumed.
58
+ *
59
+ * The execAgent group spawns the REAL installed `claude` binary (real fork+exec,
60
+ * real buildExecEnv/buildExecCommand output) with `passthroughArgs: ['--version']`
61
+ * appended. Claude's own arg parsing short-circuits on `--version` before doing
62
+ * any network/session work (verified: `claude -p "x" --permission-mode plan
63
+ * --version` exits in ~0.2s printing only the version string) — this measures
64
+ * exec.ts's own spawn overhead without paying for (or depending on) a real,
65
+ * non-deterministic, network-bound agent conversation.
66
+ */
67
+ import { describe, bench } from 'vitest';
68
+ import * as fs from 'node:fs';
69
+ import * as path from 'node:path';
70
+ import { randomUUID } from 'node:crypto';
71
+ import { buildExecEnv, buildExecCommand, execAgent } from './exec.js';
72
+ import { resetActorCache } from './actor.js';
73
+ import { getUserAgentsDir, getHistoryDir } from './state.js';
74
+ import { listInstalledVersions } from './versions.js';
75
+ function execOpts(over) {
76
+ return { mode: 'plan', effort: 'auto', cwd: process.cwd(), ...over };
77
+ }
78
+ // Discovered from the REAL version-home layout so this bench runs unmodified
79
+ // on any dev box (or degrades gracefully when nothing is installed, e.g. a
80
+ // bare CI runner -- this file is not wired into `vitest run`, see
81
+ // vitest.config.ts:9, so that only matters for a human running it locally).
82
+ const installedClaudeVersions = listInstalledVersions('claude');
83
+ const installedCodex = listInstalledVersions('codex').at(-1);
84
+ /**
85
+ * Whether a claude version's home has a resolvable oauth account email
86
+ * (claude-account-token.ts:29 readClaudeAccountEmail's own check), WITHOUT
87
+ * going through the secrets bundle at all -- this only reads the plaintext
88
+ * `.claude.json` account marker, never a secret value, so it is safe to run
89
+ * as bench setup and never prints anything sensitive.
90
+ */
91
+ function hasResolvableAccount(version) {
92
+ const home = path.join(getHistoryDir(), 'versions', 'claude', version, 'home');
93
+ for (const p of [path.join(home, '.claude', '.claude.json'), path.join(home, '.claude.json')]) {
94
+ try {
95
+ const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
96
+ if (typeof email === 'string' && email.trim().length > 0)
97
+ return true;
98
+ }
99
+ catch { /* try the next candidate path */ }
100
+ }
101
+ return false;
102
+ }
103
+ const loggedInClaudeVersion = installedClaudeVersions.find(hasResolvableAccount);
104
+ const loggedOutClaudeVersion = installedClaudeVersions.find((v) => !hasResolvableAccount(v));
105
+ const metaFile = path.join(getUserAgentsDir(), 'agents.yaml');
106
+ /**
107
+ * Force the next buildExecEnv call to pay full cold-start cost: clears the
108
+ * process-lifetime actor cache (actor.ts:157 `cached`) and bumps agents.yaml's
109
+ * mtime so state.ts:1130 readMeta()'s stamp check misses and it re-parses --
110
+ * exactly the "another process touched the file" invalidation path the module
111
+ * doc at state.ts:1120 describes.
112
+ *
113
+ * tinybench's bench-level `setup` hook (the only per-task hook vitest's
114
+ * `bench(name, fn, options)` actually exposes -- `beforeEach`/`afterEach` are
115
+ * tinybench's internal per-Task `FnOptions`, not part of the `Options` shape
116
+ * vitest forwards, and using them here is a type error) fires ONCE before a
117
+ * task's `run()`, not once per sample. So to measure a genuinely cold call
118
+ * rather than one cold sample diluted into a mean with many warm ones, every
119
+ * cold bench() below pairs this `setup` with `{ iterations: 1, time: 1,
120
+ * warmupIterations: 0, warmupTime: 0 }` -- no warmup, and a near-zero time
121
+ * budget so the run loop stops as soon as its 1-sample floor is met.
122
+ */
123
+ function invalidateCaches(_task, mode) {
124
+ if (mode !== 'run')
125
+ return;
126
+ resetActorCache();
127
+ try {
128
+ const now = new Date();
129
+ fs.utimesSync(metaFile, now, now);
130
+ }
131
+ catch {
132
+ // No ~/.agents/agents.yaml on this box -- readMeta() has nothing cached
133
+ // to invalidate either, so the cold/warm distinction collapses; proceed.
134
+ }
135
+ }
136
+ /** Bench options for a single, genuinely-cold sample — see invalidateCaches doc. */
137
+ const COLD_SAMPLE_OPTS = { setup: invalidateCaches, iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 };
138
+ describe.skipIf(!loggedInClaudeVersion || !loggedOutClaudeVersion)('buildExecEnv — resolveClaudeSetupToken cost split (exec.ts:424, claude-account-token.ts:51): same code path, signed-in vs not', () => {
139
+ bench('pinned version WITH a signed-in account (pays the uncached scrypt decrypt every call)', () => {
140
+ buildExecEnv(execOpts({ agent: 'claude', version: loggedInClaudeVersion, sessionId: randomUUID() }));
141
+ });
142
+ bench('pinned version WITHOUT a signed-in account (readClaudeAccountEmail short-circuits, claude-account-token.ts:56)', () => {
143
+ buildExecEnv(execOpts({ agent: 'claude', version: loggedOutClaudeVersion, sessionId: randomUUID() }));
144
+ });
145
+ });
146
+ describe('buildExecEnv — warm cache (steady state: loop.ts/teams/runner calling it repeatedly in one process)', () => {
147
+ bench('claude, auto-resolved version (resolveVersion + isVersionInstalled + resolveClaudeSetupToken chain)', () => {
148
+ buildExecEnv(execOpts({ agent: 'claude', sessionId: randomUUID() }));
149
+ });
150
+ bench('codex, explicit pinned version (no claude-account-token path at all)', () => {
151
+ buildExecEnv(execOpts({ agent: 'codex', version: installedCodex ?? '0.146.0', sessionId: randomUUID() }));
152
+ });
153
+ bench('codex, auto-resolved version', () => {
154
+ buildExecEnv(execOpts({ agent: 'codex', sessionId: randomUUID() }));
155
+ });
156
+ });
157
+ describe('buildExecEnv — cold cache (single sample: the first call in a fresh `agents run` process)', () => {
158
+ bench('claude, auto-resolved version', () => {
159
+ buildExecEnv(execOpts({ agent: 'claude', sessionId: randomUUID() }));
160
+ }, COLD_SAMPLE_OPTS);
161
+ bench.skipIf(!loggedOutClaudeVersion)('claude, pinned version WITHOUT a signed-in account (isolates readMeta+actor cold cost from the scrypt cost above)', () => {
162
+ buildExecEnv(execOpts({ agent: 'claude', version: loggedOutClaudeVersion, sessionId: randomUUID() }));
163
+ }, COLD_SAMPLE_OPTS);
164
+ });
165
+ describe('buildExecCommand — argv assembly (runs immediately before spawn in spawnAgent, exec.ts:1745)', () => {
166
+ bench('claude headless, explicit pinned version', () => {
167
+ buildExecCommand(execOpts({
168
+ agent: 'claude', version: loggedOutClaudeVersion ?? installedClaudeVersions.at(-1) ?? '2.1.221', prompt: 'benchmark prompt', sessionId: randomUUID(),
169
+ }));
170
+ });
171
+ bench('claude headless, auto-resolved version + model tier (re-walks resolveVersion a second time, exec.ts:972)', () => {
172
+ buildExecCommand(execOpts({
173
+ agent: 'claude', prompt: 'benchmark prompt', model: 'sonnet', sessionId: randomUUID(),
174
+ }));
175
+ });
176
+ });
177
+ describe.skipIf(!loggedOutClaudeVersion)('execAgent — real subprocess spawn (real claude binary; --version passthrough keeps it network-free)', () => {
178
+ bench('claude headless spawn, pinned version without a signed-in account (isolates spawn overhead from the scrypt cost above)', async () => {
179
+ await execAgent(execOpts({
180
+ agent: 'claude',
181
+ version: loggedOutClaudeVersion,
182
+ prompt: 'benchmark prompt',
183
+ passthroughArgs: ['--version'],
184
+ }));
185
+ }, { time: 3000, iterations: 15 });
186
+ });
package/dist/lib/exec.js CHANGED
@@ -31,6 +31,7 @@ import { isTmuxInstalled } from './tmux/binary.js';
31
31
  import { shellQuote } from './ssh-exec.js';
32
32
  import { resolveClaudeSetupToken } from './claude-account-token.js';
33
33
  import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
34
+ import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
34
35
  /**
35
36
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
36
37
  *
@@ -967,6 +968,12 @@ export function buildExecCommand(options) {
967
968
  // on the default command is parsed as a project path, not a message.
968
969
  cmd.push('--prompt', options.prompt);
969
970
  }
971
+ else if (interactive && options.agent === 'claude') {
972
+ // Claude's -p is --print, not a prompt-value flag. In an interactive run
973
+ // the initial prompt is positional; emitting `-p /continue <id>` turns a
974
+ // focus recovery into a one-shot print process that immediately exits.
975
+ cmd.push(options.prompt);
976
+ }
970
977
  else if (template.promptFlag === 'positional') {
971
978
  cmd.push(options.prompt);
972
979
  }
@@ -1285,7 +1292,7 @@ export function formatPaneTail(raw, maxLines = 30) {
1285
1292
  * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
1286
1293
  */
1287
1294
  async function runInTmux(options, executable, args) {
1288
- const { createSession, hasSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
1295
+ const { createSession, killSession, paneExitStatus, prepareSessionForResume, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
1289
1296
  const { getDefaultSocketPath } = await import('./tmux/paths.js');
1290
1297
  const { attachTmux, runTmux } = await import('./tmux/binary.js');
1291
1298
  const socket = getDefaultSocketPath();
@@ -1294,15 +1301,11 @@ async function runInTmux(options, executable, args) {
1294
1301
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
1295
1302
  // A native resume must not create a competing wrapper for a live session.
1296
1303
  // A retained dead pane is reaped before the harness resumes normally.
1297
- if (options.resume && await hasSession(name, socket)) {
1298
- const existing = await createSession({ name, cwd, socket, source: 'cli', attachExisting: true });
1304
+ if (options.resume && await prepareSessionForResume(name, socket) === 'attach') {
1299
1305
  if (options.sessionId)
1300
1306
  writeSessionAliasRecord(options.sessionId, name);
1301
- if (!existing.pane || !(await paneExitStatus(existing.pane, socket)).dead) {
1302
- await attachTmux({ socket, args: ['attach-session', '-t', name] });
1303
- return { exitCode: 0, stderr: '', stdout: '' };
1304
- }
1305
- await killSession(name, socket);
1307
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
1308
+ return { exitCode: 0, stderr: '', stdout: '' };
1306
1309
  }
1307
1310
  // SessionStart learns some harness IDs only after launch. Carry the wrapper
1308
1311
  // name into that hook so it can bind both identities durably.
@@ -1980,6 +1983,13 @@ export async function runWithFallback(options) {
1980
1983
  }
1981
1984
  for (let i = 0; i < chain.length; i++) {
1982
1985
  const { agent, version, envOverride } = chain[i];
1986
+ // Every fallback entry can target a different harness/version home. Sync
1987
+ // its active preset immediately before dispatch so entries 2..N cannot
1988
+ // inherit the stale rules file left by the primary entry.
1989
+ const rulesVersion = version ?? resolveVersion(agent);
1990
+ if (rulesVersion) {
1991
+ applyActiveRulesPresetAtRun(agent, rulesVersion, getVersionHomePath(agent, rulesVersion));
1992
+ }
1983
1993
  // Record the entry we're about to attempt so the caller (audit log) sees the
1984
1994
  // agent+version that actually ran, even after a rate-limit handoff.
1985
1995
  if (options.dispatchSink) {