@webpresso/agent-kit 3.2.0 → 3.3.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 (93) hide show
  1. package/bin/_run.js +4 -1
  2. package/dist/esm/audit/blueprint-trust.js +14 -10
  3. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  4. package/dist/esm/audit/changeset-evidence.js +30 -0
  5. package/dist/esm/audit/changeset-required.d.ts +10 -0
  6. package/dist/esm/audit/changeset-required.js +73 -0
  7. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  8. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  9. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  10. package/dist/esm/audit/registry.d.ts +4 -4
  11. package/dist/esm/audit/registry.js +6 -1
  12. package/dist/esm/blueprint/core/parser.js +61 -4
  13. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  14. package/dist/esm/blueprint/core/schema.js +26 -0
  15. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  16. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  17. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  18. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  20. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  21. package/dist/esm/blueprint/sync/client.js +1 -1
  22. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  23. package/dist/esm/blueprint/trust/dossier.js +40 -6
  24. package/dist/esm/blueprint/trust/promotion.js +12 -0
  25. package/dist/esm/build/cli-mcp-parity.js +5 -0
  26. package/dist/esm/cli/cli.d.ts +1 -1
  27. package/dist/esm/cli/cli.js +9 -0
  28. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  29. package/dist/esm/cli/commands/agent-launch.js +31 -20
  30. package/dist/esm/cli/commands/audit.js +4 -0
  31. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  32. package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
  33. package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
  34. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  35. package/dist/esm/cli/commands/init/index.js +2 -2
  36. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  37. package/dist/esm/cli/commands/init/package-root.js +10 -2
  38. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  39. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  40. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  41. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  42. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  43. package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
  44. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  45. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  46. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  47. package/dist/esm/cli/commands/quality-runner.js +3 -3
  48. package/dist/esm/cli/commands/review.js +27 -5
  49. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  50. package/dist/esm/cli/commands/rust-check.js +110 -0
  51. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  52. package/dist/esm/cli/commands/typecheck.js +30 -1
  53. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
  54. package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
  55. package/dist/esm/cli/commands/worktree/router.js +10 -0
  56. package/dist/esm/hooks/doctor.d.ts +2 -0
  57. package/dist/esm/hooks/doctor.js +37 -1
  58. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  59. package/dist/esm/hooks/shared/types.js +27 -1
  60. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  61. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  62. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  63. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  64. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  65. package/dist/esm/mcp/tools/audit.js +12 -0
  66. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  67. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  68. package/dist/esm/package.json +2 -0
  69. package/dist/esm/platform/client.d.ts +7 -1
  70. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  71. package/dist/esm/pr-description/contract.js +24 -4
  72. package/dist/esm/review/authority.js +14 -9
  73. package/dist/esm/review/availability.d.ts +11 -0
  74. package/dist/esm/review/availability.js +18 -3
  75. package/dist/esm/review/execution/adapters.d.ts +11 -1
  76. package/dist/esm/review/execution/adapters.js +10 -1
  77. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  78. package/dist/esm/review/execution/review-checkout.js +35 -16
  79. package/dist/esm/review/execution/supervisor.js +31 -4
  80. package/dist/esm/review/execution/types.d.ts +1 -0
  81. package/dist/esm/review/subject.d.ts +13 -0
  82. package/dist/esm/review/subject.js +42 -4
  83. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  84. package/dist/esm/rust/affected-manifests.js +69 -0
  85. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  86. package/dist/esm/status/snapshot.js +2 -6
  87. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  88. package/dist/esm/test-helpers/global-setup.js +18 -15
  89. package/dist/esm/typecheck/planner.d.ts +9 -1
  90. package/dist/esm/typecheck/planner.js +31 -0
  91. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  92. package/dist/esm/worktrees/orphan-scan.js +211 -0
  93. package/package.json +14 -12
@@ -1,35 +1,207 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
3
  import { dirname } from "node:path";
3
- import lockfile from "proper-lockfile";
4
+ import { isProcessAlive } from "#cli/auto-update/install-lock.js";
4
5
  import { writeFileAtomicStrict } from "#shared-utils/write-json-file.js";
5
6
  const LOCK_TIMEOUT_MS = 5_000;
6
7
  const LOCK_POLL_MS = 25;
7
8
  const LOCK_STALE_MS = 30_000;
9
+ /**
10
+ * Hard-age backstop: past this, a stale lock is reclaimed regardless of owner
11
+ * liveness. Prevents a recycled pid — or a `kill(pid,0)` that fails safe to
12
+ * "alive" (EPERM / unknown / cross-PID-namespace) — from wedging the lock
13
+ * forever. Mirrors `isInstallLockActive`, which likewise pairs liveness with an
14
+ * age bound rather than trusting liveness alone. Also serves as the reaper's own
15
+ * stale bound. Not a new deadline: it replaces the prior reaper-stale constant.
16
+ */
17
+ const LOCK_STALE_HARD_MS = 5 * 60_000;
18
+ const LOCK_OWNER_FILE = "owner";
8
19
  function sleepSync(ms) {
9
20
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
10
21
  }
11
- /** Serialize a complete read/modify/write transaction for a host config file. */
12
- export function withScaffolderFileLock(path, action) {
13
- mkdirSync(dirname(path), { recursive: true });
14
- const startedAt = Date.now();
15
- let release;
16
- while (!release) {
22
+ function isErrno(error, code) {
23
+ return error instanceof Error && "code" in error && error.code === code;
24
+ }
25
+ /** Directory age in ms, or `undefined` when the directory is gone (ENOENT). */
26
+ function directoryAgeMs(path, now) {
27
+ try {
28
+ return now - statSync(path).mtime.getTime();
29
+ }
30
+ catch (error) {
31
+ if (isErrno(error, "ENOENT"))
32
+ return undefined;
33
+ throw error;
34
+ }
35
+ }
36
+ /**
37
+ * Read the lock's owner record. The token is the full `"<pid>:<uuid>"` string
38
+ * (identical to what release compares); `pid` is its leading integer. Returns
39
+ * `undefined` for a missing/empty/unparseable owner — i.e. a crash that never
40
+ * finished writing the record, which is reclaimable once age-stale.
41
+ */
42
+ function readLockOwner(lockPath) {
43
+ let token;
44
+ try {
45
+ token = readFileSync(`${lockPath}/${LOCK_OWNER_FILE}`, "utf8");
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ const separator = token.indexOf(":");
51
+ if (separator <= 0)
52
+ return undefined;
53
+ const pid = Number(token.slice(0, separator));
54
+ if (!Number.isInteger(pid) || pid <= 0)
55
+ return undefined;
56
+ return { pid, token };
57
+ }
58
+ function tryAcquireDirectory(path) {
59
+ try {
60
+ mkdirSync(path);
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ if (isErrno(error, "EEXIST"))
65
+ return false;
66
+ throw error;
67
+ }
68
+ }
69
+ /**
70
+ * Serialize stale reclaim across contenders. A crashed reaper holder is itself
71
+ * reclaimed race-free: exactly one waiter's atomic rename of the reaper carcass
72
+ * succeeds; losers observe ENOENT and retry the mkdir. This is what stops two
73
+ * reclaimers from deleting each other's fresh lock — the original lost-update.
74
+ *
75
+ * Release is owner-token-checked, exactly like the main lock: if a paused reaper
76
+ * holder is stolen and replaced while it sleeps, its release sees a mismatched
77
+ * owner token and no-ops, so it cannot delete the successor's reaper and let a
78
+ * third reclaimer in behind it.
79
+ */
80
+ function acquireReaperLock(path, deadline, now) {
81
+ const ownerPath = `${path}/${LOCK_OWNER_FILE}`;
82
+ const ownerToken = `${process.pid}:${randomUUID()}`;
83
+ while (!tryAcquireDirectory(path)) {
84
+ const ageMs = directoryAgeMs(path, now());
85
+ if (ageMs !== undefined && ageMs >= LOCK_STALE_HARD_MS) {
86
+ const carcass = `${path}.stale-${process.pid}-${randomUUID()}`;
87
+ try {
88
+ renameSync(path, carcass);
89
+ rmSync(carcass, { recursive: true, force: true });
90
+ }
91
+ catch (error) {
92
+ // A concurrent waiter won the atomic rename; retry the mkdir.
93
+ if (!isErrno(error, "ENOENT"))
94
+ throw error;
95
+ }
96
+ continue;
97
+ }
98
+ if (now() >= deadline) {
99
+ throw new Error(`Timed out waiting for stale host config lock reaper: ${path}`);
100
+ }
101
+ sleepSync(LOCK_POLL_MS);
102
+ }
103
+ try {
104
+ writeFileSync(ownerPath, ownerToken, "utf8");
105
+ }
106
+ catch (error) {
107
+ // Mirror the main lock: don't leave a reaper others must wait out on an
108
+ // owner-write failure.
109
+ rmSync(path, { recursive: true, force: true });
110
+ throw error;
111
+ }
112
+ return () => {
17
113
  try {
18
- release = lockfile.lockSync(path, {
19
- realpath: false,
20
- stale: LOCK_STALE_MS,
21
- update: LOCK_STALE_MS / 2,
22
- });
114
+ if (readFileSync(ownerPath, "utf8") !== ownerToken)
115
+ return;
116
+ rmSync(path, { recursive: true, force: true });
23
117
  }
24
118
  catch (error) {
25
- if (error.code !== "ELOCKED")
119
+ if (!isErrno(error, "ENOENT"))
120
+ throw error;
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * Remove a stale lock while holding the reaper, so at most one reclaimer acts at
126
+ * a time. Re-checks under the reaper: only a lock that is still age-stale AND
127
+ * whose owner is absent, dead, or past the hard-age bound is deleted. A live
128
+ * owner within the hard-age window is never reclaimed, so a slow-but-alive
129
+ * writer cannot lose its lock to a successor.
130
+ */
131
+ function reclaimStaleLock(lockPath, reaperPath, deadline, now, isAlive, seams) {
132
+ const releaseReaper = acquireReaperLock(reaperPath, deadline, now);
133
+ try {
134
+ const ageMs = directoryAgeMs(lockPath, now());
135
+ if (ageMs === undefined || ageMs < LOCK_STALE_MS)
136
+ return;
137
+ const owner = readLockOwner(lockPath);
138
+ // Reclaim an absent owner record (a crash before the owner write) or a dead
139
+ // owner at the soft-stale age — recovering an abandoned lock fast is the whole
140
+ // point. A live owner waits for the hard-age backstop. The residual: a writer
141
+ // OS-suspended for 30s inside the two-statement mkdir/owner-write gap could be
142
+ // reclaimed and overlap; that window is a strict subset of the documented
143
+ // advisory-lock boundary (a suspended holder past the reclaim age), so it is
144
+ // accepted, not silently ignored.
145
+ const reclaimable = owner === undefined || !isAlive(owner.pid) || ageMs >= LOCK_STALE_HARD_MS;
146
+ if (!reclaimable)
147
+ return;
148
+ seams?.onBeforeReclaimDelete?.();
149
+ rmSync(lockPath, { recursive: true, force: true });
150
+ }
151
+ finally {
152
+ releaseReaper();
153
+ }
154
+ }
155
+ function acquireScaffolderFileLock(path, seams) {
156
+ const lockPath = `${path}.lock`;
157
+ const reaperPath = `${lockPath}.reaper`;
158
+ const ownerToken = `${process.pid}:${randomUUID()}`;
159
+ const ownerPath = `${lockPath}/${LOCK_OWNER_FILE}`;
160
+ const now = seams?.now ?? Date.now;
161
+ const isAlive = seams?.isAlive ?? isProcessAlive;
162
+ const deadline = now() + LOCK_TIMEOUT_MS;
163
+ for (;;) {
164
+ if (tryAcquireDirectory(lockPath)) {
165
+ try {
166
+ writeFileSync(ownerPath, ownerToken, "utf8");
167
+ }
168
+ catch (error) {
169
+ rmSync(lockPath, { recursive: true, force: true });
26
170
  throw error;
27
- if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
28
- throw new Error(`Timed out waiting for host config lock: ${path}`, { cause: error });
29
171
  }
30
- sleepSync(LOCK_POLL_MS);
172
+ return () => {
173
+ try {
174
+ if (readFileSync(ownerPath, "utf8") !== ownerToken)
175
+ return;
176
+ rmSync(lockPath, { recursive: true, force: true });
177
+ }
178
+ catch (error) {
179
+ if (!isErrno(error, "ENOENT"))
180
+ throw error;
181
+ }
182
+ };
183
+ }
184
+ const ageMs = directoryAgeMs(lockPath, now());
185
+ if (ageMs !== undefined && ageMs >= LOCK_STALE_MS) {
186
+ // Reclaim may be a no-op (owner still alive within the hard-age window);
187
+ // fall through to the deadline check so a live holder can't spin us
188
+ // forever, then retry the mkdir on the next iteration.
189
+ reclaimStaleLock(lockPath, reaperPath, deadline, now, isAlive, seams);
31
190
  }
191
+ if (now() >= deadline) {
192
+ throw new Error(`Timed out waiting for host config lock: ${path}`);
193
+ }
194
+ sleepSync(LOCK_POLL_MS);
32
195
  }
196
+ }
197
+ /**
198
+ * Serialize a complete read/modify/write transaction for a host config file.
199
+ * These are local single-machine home-config locks (`~/.codex`, `~/.grok`), so
200
+ * owner-pid liveness is meaningful; this is not a shared-filesystem lock.
201
+ */
202
+ export function withScaffolderFileLock(path, action, seams) {
203
+ mkdirSync(dirname(path), { recursive: true });
204
+ const release = acquireScaffolderFileLock(path, seams);
33
205
  try {
34
206
  return action();
35
207
  }
@@ -0,0 +1,107 @@
1
+ import { type OpencodeAccountAvailabilityEvent } from "#review/availability.js";
2
+ import type { ReviewLimitWindow } from "#review/execution/types.js";
3
+ import { type OpencodeAccount, type OpencodeAccountSelection } from "#review/opencode-account.js";
4
+ /** Set to `1`/`true` to launch without probing account health at all. */
5
+ export declare const OPENCODE_SKIP_PROBE_ENV = "WP_OPENCODE_SKIP_PROBE";
6
+ /**
7
+ * Wall-clock bound for a single account probe.
8
+ *
9
+ * Measured live on 2026-07-24 against three real OpenCode Go accounts
10
+ * (`opencode` 1.18.4, model `opencode-go/deepseek-v4-flash`, trivial prompt):
11
+ *
12
+ * | run | capped `a` | capped `o` | healthy `z` |
13
+ * | --- | --------------- | --------------- | -------------------------- |
14
+ * | repo cwd, default tools | 6.0s to first limit line | ~10s | 50.9s to completion |
15
+ * | temp cwd, default tools | 11.6s | 20.7s | 28.2s to first assistant text |
16
+ * | temp cwd, no-tools agent | 13.9s | 11.8s | 18.6s to first assistant text |
17
+ *
18
+ * `opencode --version` alone costs ~5.7s on the same machine, so roughly a third
19
+ * of the floor is binary startup and cannot be optimised away here. The probe
20
+ * therefore runs in a fresh temp cwd (a repo cwd made opencode snapshot the
21
+ * worktree, which alone cost ~23s) with a no-tools agent (which shrinks the
22
+ * system prompt and saved a further ~10s).
23
+ *
24
+ * 45s is ~2.4x the slowest observed healthy completion (18.6s) and ~2.2x the
25
+ * slowest observed limit detection (20.7s). The headroom covers a cold provider
26
+ * catalog fetch on a slow network; it is not a workaround for a slow probe.
27
+ * Hitting it is not a failure to be silenced by raising the bound — it degrades
28
+ * to `inconclusive` and the launch proceeds on the already-selected account.
29
+ *
30
+ * A capped account is detected by *streaming* the output rather than waiting for
31
+ * exit: opencode retries a usage-limit stream error and does not exit on it, so
32
+ * a blocking `spawnSync` sat at the full 60s bound in every measured run even
33
+ * though the limit was already on stderr after ~6s.
34
+ */
35
+ export declare const OPENCODE_PROBE_TIMEOUT_MS = 45000;
36
+ /**
37
+ * How long a successful probe is trusted before the account is probed again.
38
+ *
39
+ * A probe costs 12-28s (see `OPENCODE_PROBE_TIMEOUT_MS`), so probing a healthy
40
+ * account on every launch would make `wp opencode` permanently ~19s slower. A
41
+ * *capped* account never needs a re-probe — its cooldown record already
42
+ * suppresses it for the real reset window — so only the healthy verdict is
43
+ * memoised.
44
+ *
45
+ * Measured end to end on 2026-07-24 with three real accounts (two capped):
46
+ * a cold launch that rotated twice took 40.6s; the immediately following launch
47
+ * took 476ms, because the two capped accounts were suppressed by their recorded
48
+ * windows and the healthy one was covered by this marker.
49
+ */
50
+ export declare const OPENCODE_PROBE_HEALTHY_TTL_MS: number;
51
+ export type OpencodeProbeResult =
52
+ /** The account answered, so it has budget. */
53
+ {
54
+ readonly kind: "healthy";
55
+ }
56
+ /** The provider reported a usage limit; `retryAfterMs` is already defaulted. */
57
+ | {
58
+ readonly kind: "limited";
59
+ readonly limitWindow: ReviewLimitWindow;
60
+ readonly retryAfterMs: number;
61
+ readonly windowParsed: boolean;
62
+ }
63
+ /** Nothing could be concluded; the caller must launch on this account anyway. */
64
+ | {
65
+ readonly kind: "inconclusive";
66
+ readonly reason: string;
67
+ };
68
+ export interface OpencodeProbeRunnerInput {
69
+ readonly dataDir: string;
70
+ readonly timeoutMs: number;
71
+ }
72
+ export type OpencodeProbeRunner = (input: OpencodeProbeRunnerInput) => Promise<OpencodeProbeResult>;
73
+ export declare function isOpencodeProbeEnabled(env?: NodeJS.ProcessEnv): boolean;
74
+ export interface ProbeOpencodeAccountDeps {
75
+ readonly run?: OpencodeProbeRunner;
76
+ readonly materialize?: (key: string, dataDir: string) => void;
77
+ readonly now?: () => number;
78
+ readonly timeoutMs?: number;
79
+ readonly healthyTtlMs?: number;
80
+ }
81
+ /**
82
+ * Probe one account's OpenCode Go credential, memoising a healthy verdict.
83
+ *
84
+ * Never throws: every failure path resolves to `inconclusive` so a credential
85
+ * check can never stop the TUI from opening.
86
+ */
87
+ export declare function probeOpencodeAccount(account: OpencodeAccount, deps?: ProbeOpencodeAccountDeps): Promise<OpencodeProbeResult>;
88
+ export interface OpencodeProbeRotationDeps {
89
+ readonly probe?: (account: OpencodeAccount) => Promise<OpencodeProbeResult>;
90
+ readonly recordCooldown?: (canonicalId: string, event: OpencodeAccountAvailabilityEvent) => Promise<unknown>;
91
+ readonly selectFrom?: (accounts: readonly OpencodeAccount[], excludedIds: ReadonlySet<string>) => OpencodeAccountSelection | undefined;
92
+ readonly env?: NodeJS.ProcessEnv;
93
+ }
94
+ export interface OpencodeProbeRotation {
95
+ readonly selection: OpencodeAccountSelection;
96
+ /** Human-readable lines describing what the probe did, in print order. */
97
+ readonly lines: readonly string[];
98
+ }
99
+ /**
100
+ * Probe the selected account and rotate past capped ones, bounded by the number
101
+ * of configured accounts.
102
+ *
103
+ * Returns the account to launch on plus the lines to print. Never throws and
104
+ * never returns nothing: an inconclusive probe keeps the incoming selection, so
105
+ * the worst case is exactly today's behaviour plus one log line.
106
+ */
107
+ export declare function resolveHealthyOpencodeAccount(accounts: readonly OpencodeAccount[], initial: OpencodeAccountSelection, deps?: OpencodeProbeRotationDeps): Promise<OpencodeProbeRotation>;
@@ -0,0 +1,323 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { formatOpencodeResetWait, OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS, } from "#cli/commands/opencode-rotate.js";
6
+ import { loadOpencodeAccountAvailability, pruneExpiredOpencodeAccountSuppressions, recordOpencodeAccountAvailabilityWithLock, } from "#review/availability.js";
7
+ import { opencodeUsageLimit } from "#review/execution/adapters.js";
8
+ import { opencodeAccountCanonicalId, opencodeAccountDataDir, selectActiveOpencodeAccount, } from "#review/opencode-account.js";
9
+ import { materializeOpencodeGoAuth } from "#review/opencode-auth.js";
10
+ /** Set to `1`/`true` to launch without probing account health at all. */
11
+ export const OPENCODE_SKIP_PROBE_ENV = "WP_OPENCODE_SKIP_PROBE";
12
+ /**
13
+ * Wall-clock bound for a single account probe.
14
+ *
15
+ * Measured live on 2026-07-24 against three real OpenCode Go accounts
16
+ * (`opencode` 1.18.4, model `opencode-go/deepseek-v4-flash`, trivial prompt):
17
+ *
18
+ * | run | capped `a` | capped `o` | healthy `z` |
19
+ * | --- | --------------- | --------------- | -------------------------- |
20
+ * | repo cwd, default tools | 6.0s to first limit line | ~10s | 50.9s to completion |
21
+ * | temp cwd, default tools | 11.6s | 20.7s | 28.2s to first assistant text |
22
+ * | temp cwd, no-tools agent | 13.9s | 11.8s | 18.6s to first assistant text |
23
+ *
24
+ * `opencode --version` alone costs ~5.7s on the same machine, so roughly a third
25
+ * of the floor is binary startup and cannot be optimised away here. The probe
26
+ * therefore runs in a fresh temp cwd (a repo cwd made opencode snapshot the
27
+ * worktree, which alone cost ~23s) with a no-tools agent (which shrinks the
28
+ * system prompt and saved a further ~10s).
29
+ *
30
+ * 45s is ~2.4x the slowest observed healthy completion (18.6s) and ~2.2x the
31
+ * slowest observed limit detection (20.7s). The headroom covers a cold provider
32
+ * catalog fetch on a slow network; it is not a workaround for a slow probe.
33
+ * Hitting it is not a failure to be silenced by raising the bound — it degrades
34
+ * to `inconclusive` and the launch proceeds on the already-selected account.
35
+ *
36
+ * A capped account is detected by *streaming* the output rather than waiting for
37
+ * exit: opencode retries a usage-limit stream error and does not exit on it, so
38
+ * a blocking `spawnSync` sat at the full 60s bound in every measured run even
39
+ * though the limit was already on stderr after ~6s.
40
+ */
41
+ export const OPENCODE_PROBE_TIMEOUT_MS = 45_000;
42
+ /**
43
+ * How long a successful probe is trusted before the account is probed again.
44
+ *
45
+ * A probe costs 12-28s (see `OPENCODE_PROBE_TIMEOUT_MS`), so probing a healthy
46
+ * account on every launch would make `wp opencode` permanently ~19s slower. A
47
+ * *capped* account never needs a re-probe — its cooldown record already
48
+ * suppresses it for the real reset window — so only the healthy verdict is
49
+ * memoised.
50
+ *
51
+ * Measured end to end on 2026-07-24 with three real accounts (two capped):
52
+ * a cold launch that rotated twice took 40.6s; the immediately following launch
53
+ * took 476ms, because the two capped accounts were suppressed by their recorded
54
+ * windows and the healthy one was covered by this marker.
55
+ */
56
+ export const OPENCODE_PROBE_HEALTHY_TTL_MS = 30 * 60 * 1_000;
57
+ /**
58
+ * Cheapest OpenCode Go model at the time of writing. The gateway caps by
59
+ * account, not by model, so any model reveals the cap; this one is picked purely
60
+ * to minimise probe cost. If the id ever disappears from the catalog the probe
61
+ * fails as `inconclusive` and the launch proceeds unchanged, which is the same
62
+ * safe degradation as a spawn failure.
63
+ */
64
+ const OPENCODE_PROBE_MODEL = "opencode-go/deepseek-v4-flash";
65
+ const OPENCODE_PROBE_AGENT = "wp-health-probe";
66
+ const OPENCODE_PROBE_PROMPT = "hi";
67
+ const OPENCODE_PROBE_PERMISSIONS = { "*": "deny" };
68
+ const OPENCODE_PROBE_CONFIG = JSON.stringify({
69
+ $schema: "https://opencode.ai/config.json",
70
+ share: "disabled",
71
+ permission: OPENCODE_PROBE_PERMISSIONS,
72
+ agent: {
73
+ [OPENCODE_PROBE_AGENT]: {
74
+ description: "Minimal OpenCode Go credential health probe.",
75
+ mode: "primary",
76
+ permission: OPENCODE_PROBE_PERMISSIONS,
77
+ tools: { "*": false },
78
+ },
79
+ },
80
+ });
81
+ /** Rolling capture bound: the limit text always arrives in the newest output. */
82
+ const MAX_CAPTURE_BYTES = 64 * 1_024;
83
+ /** A rendered assistant token or a finished step proves the account answered. */
84
+ const HEALTHY_EVENT_PATTERN = /"type"\s*:\s*"(?:text|step_finish)"/u;
85
+ const HEALTH_MARKER_FILE = "wp-probe-health.json";
86
+ export function isOpencodeProbeEnabled(env = process.env) {
87
+ const raw = env[OPENCODE_SKIP_PROBE_ENV]?.trim().toLowerCase();
88
+ return raw !== "1" && raw !== "true";
89
+ }
90
+ function healthMarkerPath(dataDir) {
91
+ return join(dataDir, HEALTH_MARKER_FILE);
92
+ }
93
+ function readHealthMarker(dataDir) {
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(readFileSync(healthMarkerPath(dataDir), "utf8"));
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ if (parsed === null || typeof parsed !== "object")
102
+ return undefined;
103
+ const okAt = parsed["okAt"];
104
+ return typeof okAt === "number" && Number.isFinite(okAt) ? okAt : undefined;
105
+ }
106
+ function writeHealthMarker(dataDir, atMs) {
107
+ try {
108
+ mkdirSync(dataDir, { recursive: true });
109
+ writeFileSync(healthMarkerPath(dataDir), JSON.stringify({ okAt: atMs }), {
110
+ encoding: "utf8",
111
+ mode: 0o600,
112
+ });
113
+ }
114
+ catch {
115
+ // Best-effort memoisation: a missing marker only costs another probe.
116
+ }
117
+ }
118
+ function limitedFromEvent(message) {
119
+ const event = opencodeUsageLimit(message);
120
+ if (event === null || event.kind !== "failure")
121
+ return null;
122
+ const parsed = event.retryAfterMs;
123
+ return {
124
+ kind: "limited",
125
+ limitWindow: event.limitWindow ?? "unknown",
126
+ retryAfterMs: parsed ?? OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS,
127
+ windowParsed: parsed !== undefined,
128
+ };
129
+ }
130
+ function probeArgs() {
131
+ return [
132
+ "run",
133
+ "--pure",
134
+ "--print-logs",
135
+ "--log-level",
136
+ "ERROR",
137
+ "--format",
138
+ "json",
139
+ "--model",
140
+ OPENCODE_PROBE_MODEL,
141
+ "--agent",
142
+ OPENCODE_PROBE_AGENT,
143
+ OPENCODE_PROBE_PROMPT,
144
+ ];
145
+ }
146
+ function classifyChunk(captured) {
147
+ return (limitedFromEvent(captured) ??
148
+ (HEALTHY_EVENT_PATTERN.test(captured) ? { kind: "healthy" } : null));
149
+ }
150
+ /**
151
+ * Spawn a bounded `opencode run` against one account's isolated credential
152
+ * store and classify the first conclusive thing it says.
153
+ *
154
+ * There is no OpenCode Go HTTP endpoint anywhere in this codebase — the review
155
+ * adapter also reaches the provider only by spawning this binary — so a
156
+ * subprocess is the only available probe vehicle. Per-account isolation is the
157
+ * same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
158
+ * a live interactive TUI's global `auth.json`.
159
+ */
160
+ function spawnOpencodeProbe(input) {
161
+ return new Promise((resolve) => {
162
+ let probeCwd;
163
+ try {
164
+ probeCwd = mkdtempSync(join(tmpdir(), "wp-opencode-probe-"));
165
+ }
166
+ catch (error) {
167
+ resolve({ kind: "inconclusive", reason: describeError(error) });
168
+ return;
169
+ }
170
+ runProbeChild(probeCwd, input, resolve);
171
+ });
172
+ }
173
+ function runProbeChild(probeCwd, input, resolve) {
174
+ const child = spawn("opencode", [...probeArgs()], {
175
+ cwd: probeCwd,
176
+ env: {
177
+ ...process.env,
178
+ XDG_DATA_HOME: input.dataDir,
179
+ OPENCODE_CONFIG_CONTENT: OPENCODE_PROBE_CONFIG,
180
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
181
+ OPENCODE_DISABLE_PRUNE: "true",
182
+ },
183
+ stdio: ["ignore", "pipe", "pipe"],
184
+ });
185
+ let settled = false;
186
+ let timer;
187
+ const finish = (result) => {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ if (timer)
192
+ clearTimeout(timer);
193
+ child.kill("SIGKILL");
194
+ try {
195
+ rmSync(probeCwd, { recursive: true, force: true });
196
+ }
197
+ catch {
198
+ // Best-effort temp cleanup; the OS reclaims it either way.
199
+ }
200
+ resolve(result);
201
+ };
202
+ timer = setTimeout(() => finish({ kind: "inconclusive", reason: `probe timed out after ${input.timeoutMs}ms` }), input.timeoutMs);
203
+ let captured = "";
204
+ const onChunk = (chunk) => {
205
+ captured = (captured + chunk.toString("utf8")).slice(-MAX_CAPTURE_BYTES);
206
+ const result = classifyChunk(captured);
207
+ if (result)
208
+ finish(result);
209
+ };
210
+ child.stdout.on("data", onChunk);
211
+ child.stderr.on("data", onChunk);
212
+ child.on("error", (error) => finish({ kind: "inconclusive", reason: error.message }));
213
+ child.on("close", (code) => finish(code === 0
214
+ ? { kind: "healthy" }
215
+ : { kind: "inconclusive", reason: `opencode exited with code ${code ?? "signal"}` }));
216
+ }
217
+ function describeError(error) {
218
+ return error instanceof Error ? error.message : String(error);
219
+ }
220
+ /**
221
+ * Probe one account's OpenCode Go credential, memoising a healthy verdict.
222
+ *
223
+ * Never throws: every failure path resolves to `inconclusive` so a credential
224
+ * check can never stop the TUI from opening.
225
+ */
226
+ export async function probeOpencodeAccount(account, deps = {}) {
227
+ const now = (deps.now ?? Date.now)();
228
+ const dataDir = opencodeAccountDataDir(account.id);
229
+ const ttl = deps.healthyTtlMs ?? OPENCODE_PROBE_HEALTHY_TTL_MS;
230
+ const okAt = readHealthMarker(dataDir);
231
+ if (okAt !== undefined && now - okAt >= 0 && now - okAt < ttl)
232
+ return { kind: "healthy" };
233
+ try {
234
+ (deps.materialize ?? defaultMaterialize)(account.key, dataDir);
235
+ }
236
+ catch (error) {
237
+ return { kind: "inconclusive", reason: describeError(error) };
238
+ }
239
+ const result = await (deps.run ?? spawnOpencodeProbe)({
240
+ dataDir,
241
+ timeoutMs: deps.timeoutMs ?? OPENCODE_PROBE_TIMEOUT_MS,
242
+ });
243
+ if (result.kind === "healthy")
244
+ writeHealthMarker(dataDir, now);
245
+ return result;
246
+ }
247
+ function defaultMaterialize(key, dataDir) {
248
+ materializeOpencodeGoAuth(key, { dataDir });
249
+ }
250
+ /**
251
+ * Select the account to launch on, excluding `excludedIds`.
252
+ *
253
+ * Excluded ids are the accounts this run just cooled. They are filtered here
254
+ * rather than relied upon coming back suppressed from the store — the same
255
+ * read-after-write independence `selectNextAccount` in `opencode-rotate.ts`
256
+ * keeps, so rotation stays correct even when the cooldown write is contended.
257
+ */
258
+ function defaultSelectFrom(accounts, excludedIds) {
259
+ const pool = accounts.filter((account) => !excludedIds.has(account.id));
260
+ if (pool.length === 0)
261
+ return undefined;
262
+ const pruned = pruneExpiredOpencodeAccountSuppressions(loadOpencodeAccountAvailability());
263
+ return selectActiveOpencodeAccount(pool, pruned.state);
264
+ }
265
+ function coolLimitedAccount(accountId, result, deps) {
266
+ const canonicalId = opencodeAccountCanonicalId(accountId);
267
+ // Same event shape `--wp-rotate` and the review gate record, so all three
268
+ // writers produce identical, mutually-readable cooldown records. Unlike a
269
+ // manual rotation this one carries the provider's REAL reset window.
270
+ const event = {
271
+ failureCode: "opencode-go-usage-limit",
272
+ terminalClassification: "provider-limit-reached",
273
+ limitWindow: result.limitWindow,
274
+ retryAfterMs: result.retryAfterMs,
275
+ };
276
+ return (deps.recordCooldown ?? recordOpencodeAccountAvailabilityWithLock)(canonicalId, event);
277
+ }
278
+ function allCappedLines(selection) {
279
+ return [
280
+ `wp opencode: every OpenCode Go account probed as capped; launching on the soonest-expiring one (${selection.account.id})${formatOpencodeResetWait(selection.waitMs)}. Add another OPENCODE_GO_KEY_<ID> to widen the pool.`,
281
+ ];
282
+ }
283
+ /**
284
+ * Probe the selected account and rotate past capped ones, bounded by the number
285
+ * of configured accounts.
286
+ *
287
+ * Returns the account to launch on plus the lines to print. Never throws and
288
+ * never returns nothing: an inconclusive probe keeps the incoming selection, so
289
+ * the worst case is exactly today's behaviour plus one log line.
290
+ */
291
+ export async function resolveHealthyOpencodeAccount(accounts, initial, deps = {}) {
292
+ if (!isOpencodeProbeEnabled(deps.env ?? process.env))
293
+ return { selection: initial, lines: [] };
294
+ // `cooled` means every account is already known-capped: probing cannot find a
295
+ // better one and would cost a full timeout, so keep the existing behaviour.
296
+ if (initial.cooled)
297
+ return { selection: initial, lines: [] };
298
+ const probe = deps.probe ?? ((account) => probeOpencodeAccount(account));
299
+ const selectFrom = deps.selectFrom ?? defaultSelectFrom;
300
+ const excluded = new Set();
301
+ const lines = [];
302
+ let current = initial;
303
+ for (let attempt = 0; attempt < accounts.length; attempt += 1) {
304
+ const result = await probe(current.account);
305
+ if (result.kind === "healthy")
306
+ return { selection: current, lines };
307
+ if (result.kind === "inconclusive") {
308
+ lines.push(`wp opencode: health probe for opencode-go:${current.account.id} was inconclusive (${result.reason}); launching on it anyway.`);
309
+ return { selection: current, lines };
310
+ }
311
+ await coolLimitedAccount(current.account.id, result, deps);
312
+ excluded.add(current.account.id);
313
+ const next = selectFrom(accounts, excluded);
314
+ if (!next) {
315
+ const fallback = selectFrom(accounts, new Set()) ?? current;
316
+ lines.push(...allCappedLines(fallback));
317
+ return { selection: fallback, lines };
318
+ }
319
+ lines.push(`wp opencode: probe found opencode-go:${current.account.id} capped${formatOpencodeResetWait(result.retryAfterMs)}, rotated to opencode-go:${next.account.id}`);
320
+ current = next;
321
+ }
322
+ return { selection: current, lines };
323
+ }