pi-cohort 6.0.0 → 6.1.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 (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +36 -0
  3. package/package.json +10 -6
  4. package/src/agents/agents.ts +37 -3
  5. package/src/execution-backend/child-host-controller.ts +167 -0
  6. package/src/execution-backend/child-host-protocol.ts +101 -0
  7. package/src/execution-backend/child-host-runtime.ts +61 -0
  8. package/src/execution-backend/configured-selection.ts +19 -0
  9. package/src/execution-backend/control-channel.ts +200 -0
  10. package/src/execution-backend/control-protocol.ts +98 -0
  11. package/src/execution-backend/index.ts +23 -0
  12. package/src/execution-backend/registry.ts +96 -0
  13. package/src/execution-backend/reload.ts +351 -0
  14. package/src/execution-backend/reporting-extension.ts +144 -0
  15. package/src/execution-backend/reporting-protocol.ts +143 -0
  16. package/src/execution-backend/selection.ts +87 -0
  17. package/src/execution-backend/session-replay.ts +156 -0
  18. package/src/execution-backend/session-watcher.ts +124 -0
  19. package/src/execution-backend/testkit.ts +1011 -0
  20. package/src/execution-backend/types.ts +115 -0
  21. package/src/runs/background/async-execution.ts +15 -61
  22. package/src/runs/background/external-attempt.ts +185 -0
  23. package/src/runs/background/subagent-runner.ts +321 -27
  24. package/src/runs/foreground/attempt-finalization.ts +304 -0
  25. package/src/runs/foreground/execution.ts +200 -285
  26. package/src/runs/foreground/external-single-attempt.ts +148 -0
  27. package/src/runs/foreground/subagent-executor.ts +9 -0
  28. package/src/runs/shared/external-execution.ts +342 -0
  29. package/src/runs/shared/forward-flags.ts +2 -2
  30. package/src/runs/shared/jiti-cli.ts +66 -0
  31. package/src/runs/shared/pi-args.ts +28 -18
  32. package/src/shared/types.ts +10 -0
  33. package/src/shared/utils.ts +2 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [6.1.0] - 2026-09-12
4
+
5
+ External execution backends currently require a non-Windows host. Native execution remains supported on Windows.
6
+
7
+ ### Added
8
+
9
+ - `pi-cohort/execution-backend` registrations may include a validated, package-anchored reload descriptor (`protocolVersion`, `packageJsonUrl`, `publicSubpath`, and `factoryExport`). Detached coordinators reconstruct those public named factories in registration order without serializing live backend objects or environment state.
10
+ - Background child attempts now use the same external execution backend contract as foreground attempts while leaving the detached runner as the durable coordinator. Native selection bypasses backend reload, exact child working directories are preserved, successful delivered surfaces close, and failed or interrupted surfaces remain available for inspection.
11
+
12
+ ### Changed
13
+
14
+ - `release.sh <level>` now promotes the `## [Unreleased]` CHANGELOG section to the versioned heading and commits it with `package.json` in the single `Release X.Y.Z` commit; a missing or empty section fails the run. New CONFIG field `CHANGELOG_HEADING`.
15
+ - Release skill: a user instruction naming the level is the approval - no proposal step or re-confirmation; bundled follow-ups (ticket close, preset sync) run after `verify`.
16
+ - AGENTS.md rewritten to always-on essentials plus routing; shared core bumped to v3 (north-star communication regimes, authorization rule, docs-as-current-contract, third-party API lookup). Discovery implementation notes moved to `doc/agents-and-chains.md`.
17
+ - Added `.pi/gauntlet-overrides.md` (`tracker: github`, release path, write-gate carve-out for user-named writes).
18
+ - The `@earendil-works/pi-coding-agent` peer dependency now requires Pi >=0.85.0 for interactive execution reporting and control.
19
+ - Background selection fails loudly when an explicitly selected backend cannot reload. `auto` also reports registration/reload failures instead of silently changing configured policy; after successful reload, ordinary unavailable detection still falls back to native.
20
+
21
+ ## [6.0.1] - 2026-09-07
22
+
23
+ ### Fixed
24
+
25
+ - `getFinalOutput` no longer truncates a multi-block assistant reply to its last text block; it now joins all non-empty text blocks with `\n`. Previously a `BLOCKED:` line in an earlier block was dropped, so the `BLOCKED:` classifier misread a blocked reply as successful (or a completed reply whose trailing block happened to start with `BLOCKED:` as failed).
26
+
3
27
  ## [6.0.0] - 2026-09-07
4
28
 
5
29
  ### Removed
package/README.md CHANGED
@@ -39,6 +39,42 @@ pi install npm:pi-cohort
39
39
 
40
40
  That is the only required step.
41
41
 
42
+ ## Execution backend extension API
43
+
44
+ External execution backends are currently unsupported on Windows, for both foreground and background runs. Native execution remains supported.
45
+
46
+ `pi-cohort/execution-backend` requires Pi >=0.85.0 and is the public API for Pi extensions that add child execution surfaces. Pi's TypeScript-aware runtime loads the API; bare Node is not a supported execution path. Consumers outside Pi must supply a TypeScript-aware loader such as [jiti](https://github.com/unjs/jiti).
47
+
48
+ An adapter registers its backend in extension load order. To make that backend available to background runs, registration also supplies an optional reload descriptor:
49
+
50
+ ```ts
51
+ import {
52
+ EXECUTION_BACKEND_PROTOCOL_VERSION,
53
+ registerExecutionBackend,
54
+ type ExecutionBackendFactory,
55
+ } from "pi-cohort/execution-backend";
56
+ import { createBackend } from "./adapter.ts";
57
+
58
+ export const createExampleBackend: ExecutionBackendFactory = createBackend;
59
+
60
+ // src/execution-backend.ts, exported as "./execution-backend".
61
+ // Pi invokes the default extension; the coordinator invokes only the factory.
62
+ export default async function () {
63
+ registerExecutionBackend(await createExampleBackend(), {
64
+ reload: {
65
+ protocolVersion: EXECUTION_BACKEND_PROTOCOL_VERSION,
66
+ packageJsonUrl: new URL("../package.json", import.meta.url).href,
67
+ publicSubpath: "./execution-backend",
68
+ factoryExport: "createExampleBackend",
69
+ },
70
+ });
71
+ }
72
+ ```
73
+
74
+ The descriptor has exactly four fields. `packageJsonUrl` must be an absolute `file:` URL for the adapter package's real `package.json`. `publicSubpath` must be `"."` or an explicit non-pattern `"./..."` entry in that package's `exports`. `factoryExport` names a public, zero-argument export from that subpath; it may return the backend or a promise of it. The reconstructed backend must have the same `name` and protocol version as the original registration.
75
+
76
+ Cohort serializes only backend names and validated reload descriptors into its detached coordinator - never live backend objects or an environment capsule. The coordinator reloads registrations in their original order before selecting a backend, and every child launch receives its exact resolved `cwd`. Selecting `native` bypasses reload entirely. An explicit backend selection reports that backend's reload failure; `auto` reports any registered reload failure rather than silently changing policy. Once reload succeeds, normal auto-detection still falls back to native when no registered backend is available. Backends registered without `reload` remain usable in the foreground but cannot be reconstructed for a background run.
77
+
42
78
  ## Mental model
43
79
 
44
80
  Pi is the parent session. A subagent is a focused child Pi session with its own job. When you ask for a subagent, Pi starts the child, gives it the task, and brings the result back. Foreground runs stream in the conversation; background runs keep working and can be checked later.
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "exports": {
9
+ "./execution-backend": "./src/execution-backend/index.ts",
10
+ "./execution-backend-testkit": "./src/execution-backend/testkit.ts"
11
+ },
8
12
  "repository": {
9
13
  "type": "git",
10
14
  "url": "git+https://github.com/jjuraszek/pi-cohort.git"
@@ -59,7 +63,7 @@
59
63
  "peerDependencies": {
60
64
  "@earendil-works/pi-agent-core": "*",
61
65
  "@earendil-works/pi-ai": "*",
62
- "@earendil-works/pi-coding-agent": "*",
66
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
63
67
  "@earendil-works/pi-tui": "*"
64
68
  },
65
69
  "peerDependenciesMeta": {
@@ -81,9 +85,9 @@
81
85
  "typebox": "^1.3.11"
82
86
  },
83
87
  "devDependencies": {
84
- "@earendil-works/pi-agent-core": "^0.74.0",
85
- "@earendil-works/pi-ai": "^0.74.0",
86
- "@earendil-works/pi-coding-agent": "^0.74.0",
87
- "@earendil-works/pi-tui": "^0.74.0"
88
+ "@earendil-works/pi-agent-core": "^0.85.0",
89
+ "@earendil-works/pi-ai": "^0.85.0",
90
+ "@earendil-works/pi-coding-agent": "^0.85.0",
91
+ "@earendil-works/pi-tui": "^0.85.0"
88
92
  }
89
93
  }
@@ -18,6 +18,13 @@ export { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./
18
18
  export type AgentScope = "user" | "project" | "both";
19
19
 
20
20
  export type AgentSource = "builtin" | "user" | "project";
21
+
22
+ class InvalidExecutionBackendSettingError extends Error {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = "InvalidExecutionBackendSettingError";
26
+ }
27
+ }
21
28
  type SystemPromptMode = "append" | "replace";
22
29
  export type AgentDefaultContext = "fresh" | "fork";
23
30
 
@@ -110,6 +117,7 @@ export interface AgentConfig {
110
117
  interface SubagentSettings {
111
118
  overrides: Record<string, BuiltinAgentOverrideConfig>;
112
119
  disableBuiltins?: boolean;
120
+ executionBackend?: string;
113
121
  }
114
122
 
115
123
  const EMPTY_SUBAGENT_SETTINGS: SubagentSettings = { overrides: {} };
@@ -453,16 +461,29 @@ function readSubagentSettings(filePath: string | null): SubagentSettings {
453
461
  }
454
462
  }
455
463
 
464
+ let executionBackend: string | undefined;
465
+ if ("executionBackend" in subagentsObject) {
466
+ const value = subagentsObject.executionBackend;
467
+ if (typeof value !== "string") {
468
+ throw new InvalidExecutionBackendSettingError(`Subagent settings in '${filePath}' have invalid 'executionBackend'; expected a string.`);
469
+ }
470
+ const trimmed = value.trim();
471
+ if (trimmed === "") {
472
+ throw new InvalidExecutionBackendSettingError(`Subagent settings in '${filePath}' have invalid 'executionBackend'; must not be blank.`);
473
+ }
474
+ executionBackend = trimmed;
475
+ }
476
+
456
477
  const parsed: Record<string, BuiltinAgentOverrideConfig> = {};
457
478
  const agentOverrides = subagentsObject.agentOverrides;
458
479
  if (!agentOverrides || typeof agentOverrides !== "object" || Array.isArray(agentOverrides)) {
459
- return { overrides: parsed, disableBuiltins };
480
+ return { overrides: parsed, disableBuiltins, executionBackend };
460
481
  }
461
482
  for (const [name, value] of Object.entries(agentOverrides)) {
462
483
  const override = parseBuiltinOverrideEntry(name, value, filePath);
463
484
  if (override) parsed[name] = override;
464
485
  }
465
- return { overrides: parsed, disableBuiltins };
486
+ return { overrides: parsed, disableBuiltins, executionBackend };
466
487
  }
467
488
 
468
489
  // Merge project .pi/settings.json across every enumerated level, farthest-first
@@ -476,12 +497,17 @@ function readMergedProjectSubagentSettings(cwd: string): SubagentSettings {
476
497
 
477
498
  const overrides: Record<string, BuiltinAgentOverrideConfig> = {};
478
499
  let disableBuiltins: boolean | undefined;
500
+ let executionBackend: string | undefined;
479
501
  for (const level of levels) {
480
502
  const settingsPath = path.join(level, ".pi", "settings.json");
481
503
  let levelSettings: SubagentSettings;
482
504
  try {
483
505
  levelSettings = readSubagentSettings(settingsPath);
484
506
  } catch (error) {
507
+ // Invalid backend policy must not silently fall back to native execution.
508
+ if (error instanceof InvalidExecutionBackendSettingError) {
509
+ throw error;
510
+ }
485
511
  const message = error instanceof Error ? error.message : String(error);
486
512
  console.warn(`Skipping malformed subagent settings at '${settingsPath}': ${message}`);
487
513
  continue;
@@ -492,8 +518,16 @@ function readMergedProjectSubagentSettings(cwd: string): SubagentSettings {
492
518
  if (levelSettings.disableBuiltins !== undefined) {
493
519
  disableBuiltins = levelSettings.disableBuiltins;
494
520
  }
521
+ if (levelSettings.executionBackend !== undefined) {
522
+ executionBackend = levelSettings.executionBackend;
523
+ }
495
524
  }
496
- return { overrides, disableBuiltins };
525
+ return { overrides, disableBuiltins, executionBackend };
526
+ }
527
+
528
+ export function readProjectExecutionBackend(cwd: string): string | undefined {
529
+ const merged = readMergedProjectSubagentSettings(cwd);
530
+ return merged.executionBackend;
497
531
  }
498
532
 
499
533
  function composeOverrideTools(
@@ -0,0 +1,167 @@
1
+ import * as fs from "node:fs";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ChildHostFrameDecoder, encodeChildHostMessage, type ChildHostMessage } from "./child-host-protocol.ts";
7
+ import { PI_COHORT_CHILD_HOST_CONFIG } from "./child-host-runtime.ts";
8
+ import { ensureJitiCliPath } from "../runs/shared/jiti-cli.ts";
9
+ import type { ExecutionBackend, ExecutionBackendLease } from "./types.ts";
10
+
11
+ export interface ChildHostAttempt { readonly attemptId: string; readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly environment: Readonly<Record<string, string>>; }
12
+ export interface ChildHostExit { readonly status: number | null; readonly signal: string | null; }
13
+ export interface ChildHostController { readonly lease: ExecutionBackendLease; readonly ready: Promise<void>; startAttempt(attempt: ChildHostAttempt): Promise<ChildHostExit>; shutdown(): Promise<void>; releaseObserver(): Promise<void>; }
14
+ export interface ChildHostControllerOptions { readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
15
+ export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: () => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
16
+
17
+ const HOST_ENVIRONMENT_KEYS = ["PATH", "HOME", "TMPDIR", "SystemRoot"] as const;
18
+ function childHostCommand(ensureJiti: () => string | undefined): { command: string; args: readonly string[] } {
19
+ const jiti = ensureJiti();
20
+ if (!jiti) throw new Error("upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed");
21
+ return { command: process.execPath, args: [jiti, path.resolve(path.dirname(fileURLToPath(import.meta.url)), "child-host-runtime.ts")] };
22
+ }
23
+ function disconnected(phase: string): Error { return new Error(`child host disconnected ${phase}`); }
24
+ function aborted(): Error { return new Error("child host launch aborted"); }
25
+
26
+ export async function createChildHostController(options: ChildHostControllerOptions, dependencies: ChildHostControllerDependencies = {}): Promise<ChildHostController> {
27
+ const platform = dependencies.platform ?? process.platform;
28
+ if (platform === "win32") throw new Error("external terminal mux child host is unsupported on win32");
29
+ if (options.signal.aborted) throw aborted();
30
+ const fileSystem = dependencies.fs ?? fs;
31
+ let directory: string | undefined;
32
+ let socketPath: string | undefined;
33
+ let configPath: string | undefined;
34
+ let server: net.Server | undefined;
35
+ let socket: net.Socket | undefined;
36
+ let closed = false;
37
+ let abortListener: (() => void) | undefined;
38
+ const cleanup = () => {
39
+ if (abortListener) options.signal.removeEventListener("abort", abortListener);
40
+ abortListener = undefined;
41
+ try { socket?.destroy(); } catch {}
42
+ try { server?.close(); } catch {}
43
+ if (socketPath) try { fileSystem.unlinkSync(socketPath); } catch {}
44
+ if (configPath) try { fileSystem.unlinkSync(configPath); } catch {}
45
+ if (directory) try { fileSystem.rmdirSync(directory); } catch {}
46
+ };
47
+ try {
48
+ directory = fileSystem.mkdtempSync(path.join(os.tmpdir(), "pi-cohort-host-"));
49
+ socketPath = path.join(directory, "host.sock");
50
+ configPath = path.join(directory, "host.json");
51
+ if (Buffer.byteLength(socketPath) >= 100) throw new Error("child host socket path exceeds platform limit");
52
+ fileSystem.chmodSync(directory, 0o700);
53
+ let descriptor: number | undefined;
54
+ try {
55
+ descriptor = fileSystem.openSync(configPath, "wx", 0o600);
56
+ fileSystem.writeFileSync(descriptor, JSON.stringify({ protocolVersion: 1, socketPath, runId: options.runId, childId: options.childId }));
57
+ } finally { if (descriptor !== undefined) fileSystem.closeSync(descriptor); }
58
+
59
+ let lease: ExecutionBackendLease | undefined;
60
+ let ready = false;
61
+ let releasePromise: Promise<void> | undefined;
62
+ let active: { readonly attemptId: string; started: boolean; resolve: (exit: ChildHostExit) => void; reject: (error: Error) => void } | undefined;
63
+ let pendingShutdown: { resolve: () => void; reject: (error: Error) => void } | undefined;
64
+ let resolveReady: (() => void) | undefined;
65
+ let rejectReady: ((error: Error) => void) | undefined;
66
+ const readyPromise = new Promise<void>((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
67
+ void readyPromise.catch(() => {});
68
+ let disconnectCause: Error | undefined;
69
+ const attemptedIds = new Set<string>();
70
+ const fail = (cause: Error) => {
71
+ if (closed) return;
72
+ closed = true;
73
+ rejectReady?.(cause); rejectReady = undefined;
74
+ if (active) { active.reject(cause); active = undefined; }
75
+ if (pendingShutdown) { pendingShutdown.reject(cause); pendingShutdown = undefined; }
76
+ cleanup();
77
+ };
78
+ server = (dependencies.createServer ?? net.createServer)(connection => {
79
+ if (socket || closed) { connection.destroy(); return; }
80
+ socket = connection;
81
+ const decoder = new ChildHostFrameDecoder();
82
+ connection.on("data", data => {
83
+ let messages: ChildHostMessage[];
84
+ try { messages = decoder.push(data); } catch { connection.destroy(); return; }
85
+ for (const message of messages) {
86
+ if (message.runId !== options.runId || message.childId !== options.childId) { connection.destroy(); return; }
87
+ if (!ready) {
88
+ if (message.kind !== "host_ready") { connection.destroy(); return; }
89
+ ready = true; resolveReady?.(); resolveReady = undefined; rejectReady = undefined; continue;
90
+ }
91
+ if (message.kind === "attempt_started" && active?.attemptId === message.attemptId && !active.started) { active.started = true; continue; }
92
+ if (message.kind === "attempt_spawn_error" && active?.attemptId === message.attemptId) {
93
+ const pending = active; active = undefined; pending.reject(new Error("child host attempt spawn error")); continue;
94
+ }
95
+ if (message.kind === "attempt_exited" && active?.attemptId === message.attemptId && active.started) {
96
+ const pending = active; active = undefined; pending.resolve({ status: message.status, signal: message.signal }); continue;
97
+ }
98
+ if (message.kind === "shutdown_ack" && pendingShutdown && !active) {
99
+ const pending = pendingShutdown; pendingShutdown = undefined; closed = true; connection.end(); cleanup(); pending.resolve(); continue;
100
+ }
101
+ connection.destroy(); return;
102
+ }
103
+ });
104
+ connection.once("close", () => {
105
+ if (!closed) {
106
+ const cause = disconnected(!ready ? "before ready" : active ? "during attempt" : pendingShutdown ? "before shutdown acknowledgement" : "after ready");
107
+ disconnectCause = cause;
108
+ fail(cause);
109
+ }
110
+ });
111
+ });
112
+ server.once("error", cause => fail(cause instanceof Error ? cause : new Error("child host server error")));
113
+ abortListener = () => fail(aborted());
114
+ options.signal.addEventListener("abort", abortListener, { once: true });
115
+ await new Promise<void>((resolve, reject) => server!.listen(socketPath!, () => resolve()).once("error", reject));
116
+ fileSystem.chmodSync(socketPath, 0o600);
117
+ if (options.signal.aborted) throw aborted();
118
+ const host = dependencies.childHostCommand?.() ?? childHostCommand(dependencies.ensureJiti ?? ensureJitiCliPath);
119
+ const environment: Record<string, string> = { [PI_COHORT_CHILD_HOST_CONFIG]: configPath };
120
+ for (const key of HOST_ENVIRONMENT_KEYS) if (process.env[key]) environment[key] = process.env[key]!;
121
+ lease = await options.backend.launch({
122
+ command: host.command,
123
+ args: host.args,
124
+ cwd: options.cwd,
125
+ environment,
126
+ runId: options.runId,
127
+ childId: options.childId,
128
+ signal: options.signal,
129
+ ...(options.title === undefined ? {} : { awareness: { title: options.title } }),
130
+ });
131
+ return {
132
+ get lease() { return lease!; },
133
+ ready: readyPromise,
134
+ startAttempt(attempt) {
135
+ if (closed || !socket || !ready) return Promise.reject(disconnectCause ?? disconnected("before ready"));
136
+ if (pendingShutdown) return Promise.reject(new Error("child host is shutting down"));
137
+ if (active) return Promise.reject(new Error("child host is busy"));
138
+ if (!attempt.attemptId) return Promise.reject(new Error("attemptId is required"));
139
+ if (attemptedIds.has(attempt.attemptId)) return Promise.reject(new Error("child host attemptId has already been used"));
140
+ attemptedIds.add(attempt.attemptId);
141
+ return new Promise<ChildHostExit>((resolve, reject) => {
142
+ active = { attemptId: attempt.attemptId, started: false, resolve, reject };
143
+ socket!.write(encodeChildHostMessage({ protocolVersion: 1, kind: "start_attempt", runId: options.runId, childId: options.childId, ...attempt }), error => { if (error && active?.attemptId === attempt.attemptId) fail(disconnected("during attempt")); });
144
+ });
145
+ },
146
+ shutdown() {
147
+ if (closed) return disconnectCause ? Promise.reject(disconnectCause) : Promise.resolve();
148
+ if (!socket || !ready) return Promise.reject(disconnectCause ?? disconnected("before ready"));
149
+ if (active || pendingShutdown) return Promise.reject(new Error("child host is busy"));
150
+ return new Promise<void>((resolve, reject) => {
151
+ pendingShutdown = { resolve, reject };
152
+ socket!.write(encodeChildHostMessage({ protocolVersion: 1, kind: "shutdown_host", runId: options.runId, childId: options.childId }), error => { if (error) fail(disconnected("before shutdown acknowledgement")); });
153
+ });
154
+ },
155
+ releaseObserver() {
156
+ return (releasePromise ??= lease!.release().catch(error => {
157
+ releasePromise = undefined;
158
+ throw error;
159
+ }));
160
+ },
161
+ };
162
+ } catch (cause) {
163
+ closed = true;
164
+ cleanup();
165
+ throw cause;
166
+ }
167
+ }
@@ -0,0 +1,101 @@
1
+ export const CHILD_HOST_PROTOCOL_VERSION = 1;
2
+ export const CHILD_HOST_MAX_FRAME_BYTES = 64 * 1024;
3
+
4
+ export interface ChildHostConfig {
5
+ readonly protocolVersion: typeof CHILD_HOST_PROTOCOL_VERSION;
6
+ readonly socketPath: string;
7
+ readonly runId: string;
8
+ readonly childId: string;
9
+ }
10
+
11
+ type Correlation = { readonly protocolVersion: 1; readonly runId: string; readonly childId: string };
12
+ export type ChildHostMessage =
13
+ | (Correlation & { readonly kind: "host_ready" })
14
+ | (Correlation & { readonly kind: "start_attempt"; readonly attemptId: string; readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly environment: Readonly<Record<string, string>> })
15
+ | (Correlation & { readonly kind: "attempt_started"; readonly attemptId: string })
16
+ | (Correlation & { readonly kind: "attempt_spawn_error"; readonly attemptId: string })
17
+ | (Correlation & { readonly kind: "attempt_exited"; readonly attemptId: string; readonly status: number | null; readonly signal: string | null })
18
+ | (Correlation & { readonly kind: "shutdown_host" })
19
+ | (Correlation & { readonly kind: "shutdown_ack" });
20
+
21
+ function record(value: unknown, label: string): Record<string, unknown> {
22
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
23
+ return value as Record<string, unknown>;
24
+ }
25
+ function text(value: unknown, label: string): string {
26
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
27
+ return value;
28
+ }
29
+ function exact(input: Record<string, unknown>, fields: readonly string[]): void {
30
+ if (Object.keys(input).length !== fields.length || fields.some(field => !Object.hasOwn(input, field))) throw new Error("invalid child host message fields");
31
+ }
32
+ function correlation(input: Record<string, unknown>): Correlation {
33
+ return { protocolVersion: CHILD_HOST_PROTOCOL_VERSION, runId: text(input.runId, "runId"), childId: text(input.childId, "childId") };
34
+ }
35
+
36
+ export function validateChildHostConfig(value: unknown): ChildHostConfig {
37
+ const input = record(value, "child host config");
38
+ exact(input, ["protocolVersion", "socketPath", "runId", "childId"]);
39
+ if (input.protocolVersion !== CHILD_HOST_PROTOCOL_VERSION) throw new Error("incompatible child host protocol");
40
+ return { ...correlation(input), socketPath: text(input.socketPath, "socketPath") };
41
+ }
42
+
43
+ export function validateChildHostMessage(value: unknown): ChildHostMessage {
44
+ const input = record(value, "child host message");
45
+ if (input.protocolVersion !== CHILD_HOST_PROTOCOL_VERSION) throw new Error("incompatible child host protocol");
46
+ const base = correlation(input);
47
+ switch (input.kind) {
48
+ case "host_ready": exact(input, ["protocolVersion", "kind", "runId", "childId"]); return { ...base, kind: "host_ready" };
49
+ case "shutdown_host": exact(input, ["protocolVersion", "kind", "runId", "childId"]); return { ...base, kind: "shutdown_host" };
50
+ case "shutdown_ack": exact(input, ["protocolVersion", "kind", "runId", "childId"]); return { ...base, kind: "shutdown_ack" };
51
+ case "attempt_started": exact(input, ["protocolVersion", "kind", "runId", "childId", "attemptId"]); return { ...base, kind: "attempt_started", attemptId: text(input.attemptId, "attemptId") };
52
+ case "attempt_spawn_error": exact(input, ["protocolVersion", "kind", "runId", "childId", "attemptId"]); return { ...base, kind: "attempt_spawn_error", attemptId: text(input.attemptId, "attemptId") };
53
+ case "attempt_exited": {
54
+ exact(input, ["protocolVersion", "kind", "runId", "childId", "attemptId", "status", "signal"]);
55
+ if (input.status !== null && (!Number.isSafeInteger(input.status) || input.status < 0)) throw new Error("status must be a non-negative integer or null");
56
+ if (input.signal !== null && typeof input.signal !== "string") throw new Error("signal must be a string or null");
57
+ return { ...base, kind: "attempt_exited", attemptId: text(input.attemptId, "attemptId"), status: input.status, signal: input.signal };
58
+ }
59
+ case "start_attempt": {
60
+ exact(input, ["protocolVersion", "kind", "runId", "childId", "attemptId", "command", "args", "cwd", "environment"]);
61
+ if (!Array.isArray(input.args) || input.args.some(arg => typeof arg !== "string")) throw new Error("args must be strings");
62
+ const environment = record(input.environment, "environment");
63
+ if (Object.values(environment).some(entry => typeof entry !== "string")) throw new Error("environment values must be strings");
64
+ return { ...base, kind: "start_attempt", attemptId: text(input.attemptId, "attemptId"), command: text(input.command, "command"), args: input.args, cwd: text(input.cwd, "cwd"), environment: environment as Record<string, string> };
65
+ }
66
+ default: throw new Error("invalid child host message kind");
67
+ }
68
+ }
69
+
70
+ /** Incremental, bounded JSONL decoder. It intentionally never retains parsed frames. */
71
+ export class ChildHostFrameDecoder {
72
+ #buffer = Buffer.alloc(0);
73
+ push(chunk: Buffer | string): ChildHostMessage[] {
74
+ this.#buffer = Buffer.concat([this.#buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
75
+ const messages: ChildHostMessage[] = [];
76
+ for (;;) {
77
+ const newline = this.#buffer.indexOf(0x0a);
78
+ if (newline < 0) {
79
+ if (this.#buffer.length > CHILD_HOST_MAX_FRAME_BYTES) throw new Error("child host frame buffer exceeds maximum size");
80
+ return messages;
81
+ }
82
+ if (newline === 0 || newline > CHILD_HOST_MAX_FRAME_BYTES) throw new Error("invalid child host frame");
83
+ const frame = this.#buffer.subarray(0, newline);
84
+ this.#buffer = this.#buffer.subarray(newline + 1);
85
+ try { messages.push(validateChildHostMessage(JSON.parse(frame.toString("utf8")))); }
86
+ catch { throw new Error("invalid child host frame"); }
87
+ }
88
+ }
89
+ get remainder(): string { return this.#buffer.toString("utf8"); }
90
+ }
91
+
92
+ export function decodeChildHostFrames(chunk: string, maxBytes = CHILD_HOST_MAX_FRAME_BYTES): { messages: ChildHostMessage[]; remainder: string } {
93
+ if (maxBytes !== CHILD_HOST_MAX_FRAME_BYTES) {
94
+ if (Buffer.byteLength(chunk) > maxBytes) throw new Error("child host frame buffer exceeds maximum size");
95
+ for (const frame of chunk.split("\n").slice(0, -1)) if (!frame || Buffer.byteLength(frame) > maxBytes) throw new Error("invalid child host frame");
96
+ }
97
+ const decoder = new ChildHostFrameDecoder();
98
+ const messages = decoder.push(chunk);
99
+ return { messages, remainder: decoder.remainder };
100
+ }
101
+ export function encodeChildHostMessage(message: ChildHostMessage): string { return `${JSON.stringify(message)}\n`; }
@@ -0,0 +1,61 @@
1
+ import * as fs from "node:fs";
2
+ import * as net from "node:net";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+ import { ChildHostFrameDecoder, type ChildHostConfig, type ChildHostMessage, encodeChildHostMessage, validateChildHostConfig } from "./child-host-protocol.ts";
5
+
6
+ export const PI_COHORT_CHILD_HOST_CONFIG = "PI_COHORT_CHILD_HOST_CONFIG";
7
+
8
+ export function loadChildHostConfig(configPath = process.env[PI_COHORT_CHILD_HOST_CONFIG]): ChildHostConfig {
9
+ if (!configPath) throw new Error(`${PI_COHORT_CHILD_HOST_CONFIG} is required`);
10
+ let descriptor: number | undefined;
11
+ try {
12
+ try { descriptor = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); }
13
+ catch (cause) { if ((cause as NodeJS.ErrnoException).code === "ELOOP") throw new Error("child host config must be a regular non-symlink file"); throw cause; }
14
+ const details = fs.fstatSync(descriptor);
15
+ if (!details.isFile() || (details.mode & 0o077) !== 0) throw new Error("child host config must be an owner-only regular file");
16
+ return validateChildHostConfig(JSON.parse(fs.readFileSync(descriptor, "utf8")));
17
+ } finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
18
+ }
19
+
20
+ export function runChildHost(config = loadChildHostConfig()): void {
21
+ const correlation = { protocolVersion: 1 as const, runId: config.runId, childId: config.childId };
22
+ const socket = net.createConnection(config.socketPath);
23
+ let active: { attemptId: string; process: ChildProcess } | undefined;
24
+ let stopping = false;
25
+ const send = (message: ChildHostMessage) => { if (!socket.destroyed) socket.write(encodeChildHostMessage(message)); };
26
+ const terminateActive = () => { if (active && !active.process.killed) active.process.kill(); };
27
+ socket.on("connect", () => send({ ...correlation, kind: "host_ready" }));
28
+ socket.on("data", data => {
29
+ let messages: ChildHostMessage[];
30
+ try { messages = decoder.push(data); } catch { socket.destroy(); return; }
31
+ for (const message of messages) {
32
+ if (message.runId !== config.runId || message.childId !== config.childId) { socket.destroy(); return; }
33
+ if (message.kind === "shutdown_host") {
34
+ if (active || stopping) { socket.destroy(); return; }
35
+ stopping = true; send({ ...correlation, kind: "shutdown_ack" }); socket.end(); return;
36
+ }
37
+ if (message.kind !== "start_attempt" || active || stopping) { socket.destroy(); return; }
38
+ let child: ChildProcess;
39
+ try { child = spawn(message.command, [...message.args], { cwd: message.cwd, env: message.environment, shell: false, stdio: "inherit", windowsHide: true }); }
40
+ catch { send({ ...correlation, kind: "attempt_spawn_error", attemptId: message.attemptId }); continue; }
41
+ active = { attemptId: message.attemptId, process: child };
42
+ send({ ...correlation, kind: "attempt_started", attemptId: message.attemptId });
43
+ let settled = false;
44
+ child.once("error", () => {
45
+ if (settled) return; settled = true;
46
+ if (active?.attemptId === message.attemptId) active = undefined;
47
+ send({ ...correlation, kind: "attempt_spawn_error", attemptId: message.attemptId });
48
+ });
49
+ child.once("exit", (status, signal) => {
50
+ if (settled) return; settled = true;
51
+ if (active?.attemptId === message.attemptId) active = undefined;
52
+ send({ ...correlation, kind: "attempt_exited", attemptId: message.attemptId, status, signal });
53
+ });
54
+ }
55
+ });
56
+ const decoder = new ChildHostFrameDecoder();
57
+ socket.once("close", () => { terminateActive(); });
58
+ socket.on("error", () => { process.exitCode = 1; });
59
+ }
60
+
61
+ if (process.argv.some(argument => argument.endsWith("child-host-runtime.ts"))) runChildHost();
@@ -0,0 +1,19 @@
1
+ import { selectExecutionBackend } from "./selection.ts";
2
+ import { readProjectExecutionBackend } from "../agents/agents.ts";
3
+ import type { ExtensionConfig } from "../shared/types.ts";
4
+ import type { ExecutionBackendPreparation, ExecutionBackendSelectionResult } from "./selection.ts";
5
+
6
+ export interface ConfiguredSelectionInput {
7
+ readonly cwd: string;
8
+ readonly userConfig: ExtensionConfig;
9
+ /** @internal Detached coordinator registration loader. */
10
+ readonly preparation?: ExecutionBackendPreparation;
11
+ }
12
+
13
+ export async function selectConfiguredExecutionBackend(
14
+ input: ConfiguredSelectionInput,
15
+ ): Promise<ExecutionBackendSelectionResult> {
16
+ const projectPreference = readProjectExecutionBackend(input.cwd);
17
+ const preference = projectPreference ?? input.userConfig.executionBackend ?? "auto";
18
+ return selectExecutionBackend(preference, input.preparation);
19
+ }