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.
- package/CHANGELOG.md +24 -0
- package/README.md +36 -0
- package/package.json +10 -6
- package/src/agents/agents.ts +37 -3
- package/src/execution-backend/child-host-controller.ts +167 -0
- package/src/execution-backend/child-host-protocol.ts +101 -0
- package/src/execution-backend/child-host-runtime.ts +61 -0
- package/src/execution-backend/configured-selection.ts +19 -0
- package/src/execution-backend/control-channel.ts +200 -0
- package/src/execution-backend/control-protocol.ts +98 -0
- package/src/execution-backend/index.ts +23 -0
- package/src/execution-backend/registry.ts +96 -0
- package/src/execution-backend/reload.ts +351 -0
- package/src/execution-backend/reporting-extension.ts +144 -0
- package/src/execution-backend/reporting-protocol.ts +143 -0
- package/src/execution-backend/selection.ts +87 -0
- package/src/execution-backend/session-replay.ts +156 -0
- package/src/execution-backend/session-watcher.ts +124 -0
- package/src/execution-backend/testkit.ts +1011 -0
- package/src/execution-backend/types.ts +115 -0
- package/src/runs/background/async-execution.ts +15 -61
- package/src/runs/background/external-attempt.ts +185 -0
- package/src/runs/background/subagent-runner.ts +321 -27
- package/src/runs/foreground/attempt-finalization.ts +304 -0
- package/src/runs/foreground/execution.ts +200 -285
- package/src/runs/foreground/external-single-attempt.ts +148 -0
- package/src/runs/foreground/subagent-executor.ts +9 -0
- package/src/runs/shared/external-execution.ts +342 -0
- package/src/runs/shared/forward-flags.ts +2 -2
- package/src/runs/shared/jiti-cli.ts +66 -0
- package/src/runs/shared/pi-args.ts +28 -18
- package/src/shared/types.ts +10 -0
- 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.
|
|
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.
|
|
85
|
-
"@earendil-works/pi-ai": "^0.
|
|
86
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
87
|
-
"@earendil-works/pi-tui": "^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
|
}
|
package/src/agents/agents.ts
CHANGED
|
@@ -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
|
+
}
|