pi-crew 0.11.6 → 0.11.8

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 (58) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +216 -1
  4. package/dist/index.mjs +531 -508
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +9 -6
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +35 -0
  11. package/src/config/env-vars.ts +38 -0
  12. package/src/config/types.ts +71 -0
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/prompt/prompt-runtime.ts +33 -4
  19. package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
  20. package/src/runtime/child-pi/child-pi.ts +130 -6
  21. package/src/runtime/child-pi/pi-version.ts +187 -0
  22. package/src/runtime/child-pi/session-recovery.ts +334 -0
  23. package/src/runtime/classifier/classifier-service.ts +298 -0
  24. package/src/runtime/dispatch-batch.ts +4 -0
  25. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  26. package/src/runtime/model/model-scope.ts +141 -21
  27. package/src/runtime/model/pi-args.ts +166 -6
  28. package/src/runtime/peer-dep.ts +24 -0
  29. package/src/runtime/process/zombie-scanner.ts +168 -7
  30. package/src/runtime/rpc/README.md +139 -0
  31. package/src/runtime/rpc/frame-client.ts +328 -0
  32. package/src/runtime/rpc/rpc-worker.ts +271 -0
  33. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  34. package/src/runtime/run-worker.ts +91 -3
  35. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  36. package/src/runtime/skill-instructions.ts +66 -1
  37. package/src/runtime/surface/resolve-surface.ts +34 -19
  38. package/src/runtime/surface/surface-spawn.ts +8 -1
  39. package/src/runtime/task-output-context.ts +191 -32
  40. package/src/runtime/task-packet.ts +3 -0
  41. package/src/runtime/task-runner/child-executor.ts +172 -2
  42. package/src/runtime/task-runner/post-execution.ts +5 -3
  43. package/src/runtime/task-runner/pre-execution.ts +32 -1
  44. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  45. package/src/runtime/task-runner.ts +6 -0
  46. package/src/schema/config-schema.ts +37 -0
  47. package/src/schema/team-tool-schema.ts +14 -2
  48. package/src/state/contracts.ts +5 -0
  49. package/src/ui/format-helpers.ts +11 -0
  50. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  51. package/src/ui/pi-ui-compat.ts +18 -9
  52. package/src/ui/rail.ts +14 -1
  53. package/src/ui/settings-overlay.ts +11 -2
  54. package/src/ui/widget/index.ts +0 -1
  55. package/src/ui/widget/task-list.ts +10 -3
  56. package/src/ui/widget/widget-renderer.ts +53 -4
  57. package/src/utils/relative-time.ts +11 -3
  58. package/src/utils/visual.ts +32 -2
@@ -27,6 +27,16 @@ import { ChildPiLineObserver } from "./child-pi-streams.ts";
27
27
  // Phase 2.3: the six timer constructs moved to child-pi-timers.ts (pure motion).
28
28
  import { createChildPiTimers } from "./child-pi-timers.ts";
29
29
  import { runMockChildPi } from "./mock-fixtures.ts";
30
+ // W2 (session-file recovery): deterministic worker session flags + crash-path
31
+ // tail-replay of the session JSONL (see session-recovery.ts header).
32
+ import {
33
+ appendWorkerSessionArgs,
34
+ deriveSessionPaths,
35
+ recoverLastAssistantFromSession,
36
+ resolveSessionRecoveryEnabled,
37
+ type SessionRecoveryInfo,
38
+ shouldAttemptSessionRecovery,
39
+ } from "./session-recovery.ts";
30
40
 
31
41
  // ── Re-exports from child-pi-kill.ts (H-7 decomposition step 2) ──
32
42
  // killProcessTree is internal (not previously exported) — keep that invariant.
@@ -43,9 +53,10 @@ export { buildChildPiSpawnOptions, buildFinalChildPiSpawnOptions } from "./child
43
53
  // ── Re-export from child-pi-streams.ts (H-7 decomposition step 4) ──
44
54
  export { ChildPiLineObserver } from "./child-pi-streams.ts";
45
55
 
46
- import { checkCrewDepth, cleanupTempDir } from "../model/pi-args.ts";
56
+ import { checkCrewDepth, cleanupTempDir, resolveHermeticWorkers } from "../model/pi-args.ts";
47
57
  import { attachPostExitStdioGuard, trySignalChild } from "../process/post-exit-stdio-guard.ts";
48
58
  import { classifyProcessCrash } from "../recovery/crash-classification.ts";
59
+ import { probePiVersion } from "./pi-version.ts";
49
60
 
50
61
  /** Maximum size (bytes) for the ChildPiLineObserver's line accumulation buffer.
51
62
  * When exceeded, the buffer is force-flushed to prevent unbounded memory growth
@@ -185,8 +196,18 @@ export interface ChildPiRunInput {
185
196
  inheritContext?: boolean;
186
197
  /** Pass to pi to mark certain commands as context-excluded. Default: false */
187
198
  excludeContextBash?: boolean;
188
- /** pi session ID for session naming (aligns with pi-crew run ID) */
199
+ /** pi session ID for session naming (aligns with pi-crew run ID). W2: now
200
+ * WIRED — flows to `--session-id` (deterministic worker session files for
201
+ * crash-path tail-recovery; see session-recovery.ts). */
189
202
  sessionId?: string;
203
+ /** W2 (session-file recovery): explicit session directory override. When
204
+ * absent, derived per-worker as `<artifactsRoot>/sessions/<taskId>/` so
205
+ * session JSONL is cleaned up together with run artifacts. */
206
+ sessionDir?: string;
207
+ /** W2 (session-file recovery): explicit on/off (runtime.sessionRecovery
208
+ * config). Env PI_CREW_SESSION_RECOVERY overrides in either direction;
209
+ * default ON. Only consulted on crash-ish settles (exitCode null / killed). */
210
+ sessionRecovery?: boolean;
190
211
  /** Path to steering JSONL file for real-time steer injection. */
191
212
  steeringFile?: string;
192
213
  /** Run ID for cleanup tracking */
@@ -197,6 +218,24 @@ export interface ChildPiRunInput {
197
218
  role?: string;
198
219
  /** Team-role thinking override (takes precedence over agent.thinking). */
199
220
  thinkingOverride?: string;
221
+ /** R3-1: run-static worker header for the --append-system-prompt channel
222
+ * (renderTaskPrompt().stablePrefix). Threaded by child-executor so the
223
+ * Protocol/mailbox/workspace/runtime-context block survives compaction
224
+ * instead of living in the summarizable user-message span. */
225
+ systemPromptAppend?: string;
226
+ /** R3-19/D5 (hermetic worker spawns): thread runtime.hermeticWorkers to
227
+ * buildPiWorkerArgs (--no-extensions). Env PI_CREW_HERMETIC_WORKERS
228
+ * overrides either way; default TRUE. */
229
+ hermeticWorkers?: boolean;
230
+ /** U9 (pi 1.0.4 version floor): host pi binary version for the argv
231
+ * builder's version-gated flags (--no-mcp at the 1.0.4 floor). runChildPi
232
+ * OVERWRITES this with the single-flight probePiVersion() result whenever
233
+ * this spawn can consume a version-gated flag (parity non-hermetic + not
234
+ * pre-aborted); on hermetic spawns (the default — no version-gated flag
235
+ * consulted) the field passes through untouched and the probe is SKIPPED,
236
+ * keeping default-path spawns (and unit-test surface paths) free of a real
237
+ * `pi --version` child. See child-pi/pi-version.ts for the floor registry. */
238
+ piVersion?: string | null;
200
239
  /** Root directory for artifacts (used to validate transcriptPath). */
201
240
  artifactsRoot?: string;
202
241
  /** I5: run events JSONL path — threaded to the worker so its scratchpad
@@ -283,6 +322,19 @@ export interface ChildPiRunResult {
283
322
  exitStatus?: WorkerExitStatus;
284
323
  /** True if the agent was hard-aborted (max_turns + grace exceeded). */
285
324
  aborted?: boolean;
325
+ /**
326
+ * DR5 (RPC double-execution guard, deep-review 2026-10-05 §2): set by the
327
+ * RPC transport when a session event (message_start/message_end/…) was
328
+ * observed after the prompt was sent but the turn never settled — i.e.
329
+ * the agent turn HAD begun when the transport failed, even though no
330
+ * assistant text was captured. `isEarlyRpcTransportFailure` treats this
331
+ * as non-retryable: the turn may already have performed side effects, and
332
+ * double-executing a task that already produced work is worse than
333
+ * surfacing the transport error. An explicit result field (not an
334
+ * error-string marker) so the contract is testable and greppable; the
335
+ * stdio transport never sets it.
336
+ */
337
+ rpcAgentStarted?: boolean;
286
338
  /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
287
339
  steered?: boolean;
288
340
  /** #7 hardening: bounded digest of intermediate findings (last N tool results or
@@ -291,6 +343,13 @@ export interface ChildPiRunResult {
291
343
  * text) still produce a non-empty result. Consumers should prefer rawFinalText
292
344
  * first — this is a last-resort fallback. */
293
345
  intermediateFindings?: string;
346
+ /** W2 (session-file recovery): present when the worker died WITHOUT a
347
+ * final assistant event (exitCode null / killed) and a COMPLETE assistant
348
+ * record was tail-recovered from the worker's session JSONL (deterministic
349
+ * `--session-id`/`--session-dir` under the run artifacts root). Consumers
350
+ * treat it as a crash-path supplement to rawFinalText — never a replacement
351
+ * for clean-path output, and manifest polling is untouched. */
352
+ recoveredFromSession?: SessionRecoveryInfo;
294
353
  /**
295
354
  * MuxSurface A1: present ONLY when the worker booted inside a multiplexer
296
355
  * pane instead of a stdio pipe (spec §13.1). Downstream (T9 EventLogTailSource,
@@ -371,6 +430,17 @@ function safeLoadSurfaceConfig(cwd: string): PiTeamsConfig {
371
430
  }
372
431
  }
373
432
 
433
+ /**
434
+ * D1/D5: flags that apply to HEADLESS worker spawns only. The surface TUI
435
+ * branch strips them before booting a pane — a pane hosts a full interactive
436
+ * pi session where the interactive trust prompt must stay available and the
437
+ * ambient extension stack is part of the human-visible session (task mandate:
438
+ * worker spawn CHỈ, KHÔNG đụng surface TUI spawn).
439
+ */
440
+ function stripHeadlessOnlyFlags(args: string[]): string[] {
441
+ return args.filter((arg) => arg !== "--no-approve" && arg !== "--no-extensions");
442
+ }
443
+
374
444
  /**
375
445
  * MuxSurface A1 spawn branch (spec §13.1). Returns a ChildPiRunResult when the
376
446
  * worker was booted INSIDE a multiplexer pane (no stdio process is spawned and
@@ -413,7 +483,7 @@ async function trySurfaceBranch(
413
483
  livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
414
484
  taskId,
415
485
  cwd: input.cwd,
416
- piArgs: builtArgs,
486
+ piArgs: stripHeadlessOnlyFlags(builtArgs),
417
487
  stateRoot: input.eventsPath ? path.dirname(input.eventsPath) : "",
418
488
  baseDir: surfaceOpts?.baseDir,
419
489
  deps: preResolved ? { provider: preResolved } : {},
@@ -681,9 +751,39 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
681
751
  brokerSpawn = undefined;
682
752
  }
683
753
  }
684
- const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
754
+ // U9 (pi 1.0.4 version floor): resolve the host pi binary's version ONCE per
755
+ // process (probePiVersion — single-flight memoized, injectable exec seam,
756
+ // never rejects) and thread it into the argv builder via input. Probed ONLY
757
+ // when this spawn can consume a version-gated flag: the parity
758
+ // (non-hermetic) argv applies the --no-mcp floor at 1.0.4, while hermetic
759
+ // spawns (the default) are already MCP-clean via --no-extensions and consult
760
+ // no version-gated flag — skipping the probe there keeps every default-path
761
+ // spawn — and the unit-test surface paths — free of a real `pi --version`
762
+ // child. An already-aborted signal skips too: prepareSpawnContext's B5
763
+ // guard below returns before the argv is ever consumed.
764
+ const versionedInput: ChildPiRunInput =
765
+ !input.signal?.aborted && !resolveHermeticWorkers(input.hermeticWorkers) ? { ...input, piVersion: await probePiVersion() } : input;
766
+ const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...versionedInput, brokerSpawn } : versionedInput, effectiveTask, depthEnv);
685
767
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
686
768
  const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
769
+ // W2 (session-file recovery): resolve the worker's deterministic session
770
+ // identity (per-worker dir under the run artifacts root) and arm it on BOTH
771
+ // argv views — the headless spawnSpec.args ([script, ...builtArgs]) and the
772
+ // raw builtArgs (also consumed by the surface branch). prepareSpawnContext
773
+ // does not forward session identity to the arg builder (ownership boundary
774
+ // with child-pi-spawn.ts — see the lane report), so this is the in-zone
775
+ // activation point; appendWorkerSessionArgs is idempotent, making the move
776
+ // to builder-side forwarding later a zero-diff no-op.
777
+ const workerSession = deriveSessionPaths(input);
778
+ const sessionRecoveryEnabled = resolveSessionRecoveryEnabled(input.sessionRecovery);
779
+ if (workerSession) {
780
+ try {
781
+ fs.mkdirSync(workerSession.sessionDir, { recursive: true });
782
+ } catch {
783
+ // Unwritable target → spawn proceeds; recovery simply finds no files.
784
+ }
785
+ appendWorkerSessionArgs(spawnSpec.args, builtArgs, workerSession);
786
+ }
687
787
  // MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
688
788
  // spawning a stdio pipe. Returns a finished result in surface mode (pane exit
689
789
  // awaited) or null → fall through to the classic headless spawn below.
@@ -869,6 +969,22 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
869
969
  clearPostExitGuard();
870
970
  };
871
971
 
972
+ // W2 (session-file recovery): crash-path augment — when the worker died
973
+ // WITHOUT a final assistant event (exitCode null / killed), tail-replay
974
+ // its session JSONL and surface the last COMPLETE assistant record on the
975
+ // resolved result. Best-effort and deadline-bounded (session-recovery.ts)
976
+ // so settle can never hang on pathological IO. This AUGMENTS the result —
977
+ // manifest polling (heartbeat-watcher / detached-run-results) is untouched.
978
+ const recoverForSettle = async (settleResult: ChildPiRunResult): Promise<SessionRecoveryInfo | undefined> => {
979
+ if (!workerSession || !sessionRecoveryEnabled) return undefined;
980
+ if (!shouldAttemptSessionRecovery(settleResult, hardKilled)) return undefined;
981
+ try {
982
+ return (await recoverLastAssistantFromSession(workerSession.sessionDir, workerSession.sessionId)) ?? undefined;
983
+ } catch {
984
+ return undefined;
985
+ }
986
+ };
987
+
872
988
  const settle = (result: ChildPiRunResult): Promise<void> => {
873
989
  if (settled) return Promise.resolve();
874
990
  settled = true;
@@ -885,7 +1001,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
885
1001
  // durable transcript on disk.
886
1002
  return lineObserver
887
1003
  .flush()
888
- .then(() => {
1004
+ .then(async () => {
889
1005
  input.signal?.removeEventListener("abort", abort);
890
1006
  input.signal?.removeEventListener("abort", onParentAbort);
891
1007
  try {
@@ -893,12 +1009,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
893
1009
  } catch (error) {
894
1010
  cleanupErrors.push(error instanceof Error ? error.message : String(error));
895
1011
  }
1012
+ // W2: crash-path session tail-recovery (await BEFORE resolve so the
1013
+ // recovered record rides the same settled result object).
1014
+ const recoveredFromSession = await recoverForSettle(result);
896
1015
  // Catch all errors from settle to prevent unhandled rejection from propagating
897
1016
  try {
898
1017
  resolve({
899
1018
  ...result,
900
1019
  rawFinalText: lineObserver.getRawFinalText(),
901
1020
  intermediateFindings: lineObserver.getIntermediateFindings(),
1021
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
902
1022
  exitStatus: result.exitStatus ?? {
903
1023
  exitCode: result.exitCode,
904
1024
  cancelled: abortRequested,
@@ -926,7 +1046,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
926
1046
  );
927
1047
  }
928
1048
  })
929
- .catch((flushError) => {
1049
+ .catch(async (flushError) => {
930
1050
  // Drain failed — log and still resolve so runChildPi doesn't hang.
931
1051
  logInternalError(
932
1052
  "child-pi.settle-flush-failed",
@@ -940,11 +1060,15 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
940
1060
  } catch (error) {
941
1061
  cleanupErrors.push(error instanceof Error ? error.message : String(error));
942
1062
  }
1063
+ // W2: crash-path session tail-recovery — same augment on the
1064
+ // drain-failed path (a torn flush is itself crash-adjacent).
1065
+ const recoveredFromSession = await recoverForSettle(result);
943
1066
  try {
944
1067
  resolve({
945
1068
  ...result,
946
1069
  rawFinalText: lineObserver.getRawFinalText(),
947
1070
  intermediateFindings: lineObserver.getIntermediateFindings(),
1071
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
948
1072
  exitStatus: result.exitStatus ?? {
949
1073
  exitCode: result.exitCode,
950
1074
  cancelled: abortRequested,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * pi-version.ts — host `pi` binary version probe + hand-rolled comparison
3
+ * (U9, pi 1.0.4 adoption wave).
4
+ *
5
+ * WHY HAND-ROLLED (no `semver` dependency): pi-crew's package policy is ZERO
6
+ * new runtime dependencies (AGENTS.md / wave constraint). The comparison
7
+ * surface pi-crew actually needs is major.minor.patch + simple pre-release
8
+ * ordering + conservative null on malformed input — pulling in `semver` for
9
+ * that costs a dependency (plus its transitive tree) that every install pays
10
+ * for a decision made at most once per process. If a richer need appears
11
+ * (ranges, build-metadata ordering), REVISIT this decision explicitly — do
12
+ * not silently grow this file into a partial semver implementation.
13
+ *
14
+ * PROBE CONTRACT (probePiVersion):
15
+ * - Execs the SAME binary the stdio transport spawns: getPiSpawnCommand
16
+ * (PI_TEAMS_PI_BIN aware) with `--version` — probing a different install
17
+ * than the one we spawn would gate flags on the wrong version.
18
+ * - The exec is INJECTABLE (ExecPiVersionFn seam) so unit tests never spawn
19
+ * a real child; the default seam wraps node:child_process execFile with a
20
+ * 5s timeout (utf8).
21
+ * - Single-flight memoized per process: the FIRST call creates the promise;
22
+ * every later call (any args) returns the same one. Failures memoize too
23
+ * (null = unknown) — a missing/old pi binary does not re-probe per spawn.
24
+ * - NEVER rejects: spawn error / nonzero exit / timeout / unparseable
25
+ * stdout all resolve null. The result is a hint, never a failure channel.
26
+ *
27
+ * COMPARISON CONTRACT (comparePiVersion):
28
+ * - Strict `major.minor.patch[-prerelease][+build]` (official semver shape;
29
+ * leading zeros rejected). Build metadata is IGNORED per semver §10.
30
+ * - Malformed input (either side, incl. null/undefined) → null — callers
31
+ * treat null as "unknown" and conservatively emit NO version-gated flag.
32
+ * - Pre-release ordering (semver §11, simplified): release > pre-release;
33
+ * identifiers compared pairwise (numeric identifiers numerically and
34
+ * LOWER than alphanumeric; alphanumeric lexically); a shorter identifier
35
+ * list is lower when all preceding identifiers are equal.
36
+ */
37
+
38
+ import { execFile } from "node:child_process";
39
+ import { promisify } from "node:util";
40
+ import { getPiSpawnCommand } from "../pi-spawn.ts";
41
+
42
+ /** Probe timeout (ms). `pi --version` is a fast print; 5s absorbs slow cold starts. */
43
+ export const PI_VERSION_PROBE_TIMEOUT_MS = 5_000;
44
+
45
+ /**
46
+ * Injectable exec seam for {@link probePiVersion}. Mirrors the
47
+ * promisify(execFile) shape: resolves {stdout, stderr} on exit 0, REJECTS on
48
+ * spawn error / nonzero exit / timeout. Tests pass a fake — the default
49
+ * seam would spawn a real child (forbidden in unit tests).
50
+ */
51
+ export type ExecPiVersionFn = (
52
+ file: string,
53
+ args: string[],
54
+ options: { timeout: number; encoding: "utf8" },
55
+ ) => Promise<{ stdout?: string; stderr?: string }>;
56
+
57
+ const execFileAsync = promisify(execFile);
58
+
59
+ const defaultExec: ExecPiVersionFn = (file, args, options) => execFileAsync(file, args, options);
60
+
61
+ /**
62
+ * ── Version-floor registry (U9) ─────────────────────────────────────────────
63
+ * Every argv flag pi-crew gates on the HOST pi binary's version is listed
64
+ * here so "which pi needs which flag" is greppable in ONE place. The emit
65
+ * sites live in src/runtime/model/pi-args.ts (parity non-hermetic block).
66
+ *
67
+ * - `1.0.4` → `--no-mcp`: disconnects ambient MCP servers outright ("no
68
+ * servers connect, no MCP tools or /mcp" — cli.md:198), strictly stronger
69
+ * than the U2-lite `mcp__*` --exclude-tools tool-cut. Emitted ONLY on
70
+ * parity (non-hermetic) spawns whose probed piVersion >= this floor.
71
+ *
72
+ * Future candidates: none yet. When adding one, append the floor constant
73
+ * here + the gate at the emit site — pre-floor hosts must NEVER see the flag
74
+ * (pi's strict parser rejects unknown options with a nonzero exit).
75
+ */
76
+ export const PI_NO_MCP_FLOOR = "1.0.4";
77
+
78
+ // Probe-side extraction is LENIENT (tolerates "pi 1.0.4" / banners); the
79
+ // strict validation lives in parseVersion below. First version-shaped token
80
+ // in stdout wins.
81
+ const VERSION_TOKEN_RE = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/;
82
+
83
+ // Strict semver shape (official regex, numeric identifiers without leading
84
+ // zeros). Anything else is "unknown" — never a guess.
85
+ const STRICT_SEMVER_RE =
86
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
87
+
88
+ interface ParsedVersion {
89
+ major: number;
90
+ minor: number;
91
+ patch: number;
92
+ /** null = release (no pre-release). Build metadata is dropped at parse. */
93
+ prerelease: string[] | null;
94
+ }
95
+
96
+ function parseVersion(value: string | null | undefined): ParsedVersion | null {
97
+ if (typeof value !== "string") return null;
98
+ const match = STRICT_SEMVER_RE.exec(value.trim());
99
+ if (!match) return null;
100
+ const major = Number(match[1]);
101
+ const minor = Number(match[2]);
102
+ const patch = Number(match[3]);
103
+ const prerelease = match[4] === undefined ? null : match[4].split(".");
104
+ return { major, minor, patch, prerelease };
105
+ }
106
+
107
+ /** Semver §11 identifier compare: numeric < alphanumeric; numeric numeric-ly, alphanumeric lexically. */
108
+ function comparePrereleaseIdentifiers(a: string, b: string): -1 | 0 | 1 {
109
+ const aNumeric = /^\d+$/.test(a);
110
+ const bNumeric = /^\d+$/.test(b);
111
+ if (aNumeric && bNumeric) {
112
+ const delta = Number(a) - Number(b);
113
+ return delta < 0 ? -1 : delta > 0 ? 1 : 0;
114
+ }
115
+ if (aNumeric) return -1; // numeric identifiers have LOWER precedence
116
+ if (bNumeric) return 1;
117
+ return a < b ? -1 : a > b ? 1 : 0;
118
+ }
119
+
120
+ /**
121
+ * Compare two pi version strings. Returns -1 | 0 | 1, or null when EITHER
122
+ * side is malformed/unknown (conservative — callers must treat null as
123
+ * "emit nothing version-gated"). Build metadata is ignored (semver §10).
124
+ */
125
+ export function comparePiVersion(a: string | null | undefined, b: string | null | undefined): -1 | 0 | 1 | null {
126
+ const pa = parseVersion(a);
127
+ const pb = parseVersion(b);
128
+ if (!pa || !pb) return null;
129
+ for (const key of ["major", "minor", "patch"] as const) {
130
+ if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
131
+ }
132
+ if (pa.prerelease === null && pb.prerelease === null) return 0;
133
+ if (pa.prerelease === null) return 1; // release > pre-release
134
+ if (pb.prerelease === null) return -1;
135
+ const ids = pa.prerelease;
136
+ const other = pb.prerelease;
137
+ for (let i = 0; i < Math.min(ids.length, other.length); i++) {
138
+ const cmp = comparePrereleaseIdentifiers(ids[i] as string, other[i] as string);
139
+ if (cmp !== 0) return cmp;
140
+ }
141
+ if (ids.length !== other.length) return ids.length < other.length ? -1 : 1; // larger set wins
142
+ return 0;
143
+ }
144
+
145
+ /**
146
+ * Floor gate: true ONLY when `version` parses and is >= `floor`. Unknown
147
+ * (null/undefined) or malformed versions are BELOW every floor — the
148
+ * conservative "never emit a flag the host may reject" posture.
149
+ */
150
+ export function piVersionAtLeast(version: string | null | undefined, floor: string): boolean {
151
+ const cmp = comparePiVersion(version, floor);
152
+ return cmp !== null && cmp >= 0;
153
+ }
154
+
155
+ /** Memoized single-flight probe promise (null = unknown, failures included). */
156
+ let memoizedProbe: Promise<string | null> | null = null;
157
+
158
+ async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null> {
159
+ const { command, args } = getPiSpawnCommand(["--version"]);
160
+ const { stdout } = await exec(command, args, { timeout: PI_VERSION_PROBE_TIMEOUT_MS, encoding: "utf8" });
161
+ const text = typeof stdout === "string" ? stdout : "";
162
+ const hit = VERSION_TOKEN_RE.exec(text);
163
+ return hit ? hit[0] : null;
164
+ }
165
+
166
+ /**
167
+ * Resolve the host pi binary's version (`pi --version`), single-flight
168
+ * memoized per process. The `exec` seam is consumed only by the call that
169
+ * actually creates the memo — later calls reuse the settled promise no
170
+ * matter what they pass. NEVER rejects; unknown resolves null.
171
+ */
172
+ export function probePiVersion(exec: ExecPiVersionFn = defaultExec): Promise<string | null> {
173
+ if (!memoizedProbe) memoizedProbe = runPiVersionProbe(exec).catch(() => null);
174
+ return memoizedProbe;
175
+ }
176
+
177
+ /** @internal Test-only: drop the memoized probe so the next call re-probes with a fresh seam. */
178
+ export function __test_resetPiVersionMemo(): void {
179
+ memoizedProbe = null;
180
+ }
181
+
182
+ /** @internal Test-only: is a probe memoized? (null = no probe has run yet —
183
+ * lets tests prove a code path never TOUCHED the probe, which the call
184
+ * counter alone cannot show once single-flight kicks in). */
185
+ export function __test_peekPiVersionMemo(): Promise<string | null> | null {
186
+ return memoizedProbe;
187
+ }