pi-subagents 0.67.0 → 0.68.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 +70 -0
- package/README.md +1 -1
- package/docs/agents.md +37 -12
- package/docs/configuration.md +61 -19
- package/docs/extension-api.md +5 -1
- package/docs/missions.md +2 -2
- package/docs/models.md +11 -79
- package/docs/observability.md +18 -8
- package/docs/standalone-background.md +13 -3
- package/docs/tool-reference.md +15 -12
- package/docs/watchdog.md +10 -12
- package/docs/workflows.md +11 -1
- package/index.ts +5 -2
- package/package.json +4 -2
- package/runner-peer-loader.mjs +24 -0
- package/runner-peer-preload.mjs +25 -11
- package/skills/pi-subagents/SKILL.md +18 -21
- package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
- package/skills/pi-subagents/references/execution-controls.md +4 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
- package/skills/pi-subagents/references/review-and-validation.md +3 -3
- package/src/agents/agent-management.ts +57 -58
- package/src/agents/agent-serializer.ts +4 -3
- package/src/agents/agents.ts +184 -71
- package/src/agents/chain-serializer.ts +5 -0
- package/src/agents/runtime-agent-registry.ts +7 -6
- package/src/api/preflight.ts +20 -16
- package/src/api/required-child-extensions.ts +6 -0
- package/src/extension/config.ts +10 -37
- package/src/extension/fanout-child.ts +3 -0
- package/src/extension/herdr-pi-bridge.ts +160 -0
- package/src/extension/index.ts +42 -31
- package/src/extension/public-execution.ts +3 -3
- package/src/extension/schemas.ts +16 -5
- package/src/extension/tool-description.ts +8 -7
- package/src/intercom/native-supervisor-channel.ts +22 -18
- package/src/policy/authority.ts +4 -0
- package/src/profiles/profiles.ts +12 -6
- package/src/runs/background/active-run-index.ts +17 -1
- package/src/runs/background/async-execution.ts +309 -126
- package/src/runs/background/async-job-tracker.ts +8 -6
- package/src/runs/background/async-resume.ts +13 -4
- package/src/runs/background/async-status.ts +15 -4
- package/src/runs/background/auto-drain.ts +20 -10
- package/src/runs/background/binary-bootstrap.ts +5 -0
- package/src/runs/background/chain-append.ts +1 -1
- package/src/runs/background/chain-root-attachment.ts +14 -33
- package/src/runs/background/notify.ts +74 -6
- package/src/runs/background/result-files.ts +8 -4
- package/src/runs/background/result-watcher.ts +19 -2
- package/src/runs/background/run-child-session.ts +20 -29
- package/src/runs/background/runner-aliases.ts +4 -33
- package/src/runs/background/runner-child-launch.ts +4 -1
- package/src/runs/background/runner-child-sessions.ts +2 -2
- package/src/runs/background/runner-http-dispatcher.ts +119 -0
- package/src/runs/background/scheduled-runs.ts +11 -5
- package/src/runs/background/stale-run-reconciler.ts +35 -11
- package/src/runs/background/subagent-runner.ts +396 -275
- package/src/runs/background/subagent-wait.ts +128 -23
- package/src/runs/background/wait-completions.ts +75 -27
- package/src/runs/background/wait-subscriptions.ts +9 -3
- package/src/runs/background/wait-tool.ts +4 -2
- package/src/runs/foreground/async-stop-action.ts +93 -3
- package/src/runs/foreground/execution.ts +91 -218
- package/src/runs/foreground/foreground-history.ts +2 -1
- package/src/runs/foreground/subagent-executor.ts +266 -80
- package/src/runs/shared/acceptance.ts +34 -10
- package/src/runs/shared/async-status-projection.ts +123 -33
- package/src/runs/shared/child-launch-plan.ts +15 -3
- package/src/runs/shared/child-launch.ts +19 -6
- package/src/runs/shared/child-runtime-config.ts +5 -0
- package/src/runs/shared/child-session.ts +94 -50
- package/src/runs/shared/child-tool-plan.ts +28 -16
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/external-cli-contract.ts +11 -1
- package/src/runs/shared/external-cli-preflight.ts +6 -2
- package/src/runs/shared/herdr-connection.ts +134 -0
- package/src/runs/shared/herdr-external-adapters.ts +169 -0
- package/src/runs/shared/herdr-machine.ts +279 -0
- package/src/runs/shared/herdr-pi-protocol.ts +59 -0
- package/src/runs/shared/herdr-placed-run.ts +263 -0
- package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
- package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
- package/src/runs/shared/model-scope.ts +1 -1
- package/src/runs/shared/nested-events.ts +11 -2
- package/src/runs/shared/parallel-utils.ts +7 -2
- package/src/runs/shared/pi-spawn.ts +1 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
- package/src/runs/shared/worktree-setup-command.ts +27 -4
- package/src/runs/shared/worktree.ts +3 -3
- package/src/shared/child-cache-retention.ts +43 -0
- package/src/shared/launch-contract.ts +6 -9
- package/src/shared/pruned-fork.ts +1 -1
- package/src/shared/required-child-extensions.ts +81 -0
- package/src/shared/settings.ts +5 -2
- package/src/shared/shortcuts.ts +0 -4
- package/src/shared/types.ts +70 -29
- package/src/slash/slash-commands.ts +0 -6
- package/src/slash/subagents-admin.ts +13 -9
- package/src/tui/render.ts +20 -10
- package/src/watchdog/child-status.ts +28 -36
- package/src/watchdog/lsp-diagnostics.ts +1 -1
- package/src/watchdog/model-selection.ts +1 -1
- package/src/watchdog/register-child.ts +10 -3
- package/src/watchdog/register-main.ts +20 -20
- package/src/watchdog/render.ts +1 -1
- package/src/watchdog/review.ts +14 -30
- package/src/watchdog/rules.ts +1 -1
- package/src/watchdog/runtime.ts +23 -12
- package/src/watchdog/settings.ts +3 -6
- package/src/watchdog/types.ts +3 -5
- package/src/watchdog/warning-format.ts +1 -1
- package/src/workflows/scripted-workflow.ts +42 -3
- package/src/workflows/workflow-receipt.ts +21 -3
- package/src/workflows/workflow-resources.ts +13 -2
- package/src/runs/shared/model-exclusions.ts +0 -374
- package/src/runs/shared/readonly-model-continuation.ts +0 -69
- package/src/runs/shared/readonly-session-evidence.ts +0 -307
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as net from "node:net";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import type { HerdrMachineReference } from "../../shared/types.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_DISCOVERY_BYTES = 256 * 1024;
|
|
9
|
+
const MAX_RPC_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
const SUBSCRIPTION_ACK_TIMEOUT_MS = 15_000;
|
|
11
|
+
export const HERDR_REMOTE_PATH = "$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
12
|
+
export const HERDR_SSH_BASE = ["-T", "-o", "BatchMode=yes", "-o", "NumberOfPasswordPrompts=0", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=10", "-o", "ConnectionAttempts=1", "-o", "ForwardAgent=no", "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4", "-o", "SendEnv=-*", "-o", "SetEnv=PI_SUBAGENTS_SSH_GUARD="] as const;
|
|
13
|
+
export function hardenedSshEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { PATH: process.platform === "win32" ? source.PATH : "/usr/bin:/bin:/usr/sbin:/sbin", ...(source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}), ...(source.WINDIR ? { WINDIR: source.WINDIR } : {}) }; }
|
|
14
|
+
export function herdrSshArgs(source: NodeJS.ProcessEnv = process.env): string[] { return [...HERDR_SSH_BASE, ...(source.SSH_AUTH_SOCK ? ["-o", `IdentityAgent=${source.SSH_AUTH_SOCK}`] : [])]; }
|
|
15
|
+
|
|
16
|
+
export interface HerdrEndpoint { socket: string; session: string | null; version: string; protocol: number; compatible: boolean; running: boolean }
|
|
17
|
+
export interface HerdrRpcClient {
|
|
18
|
+
call<T>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
19
|
+
subscribe(subscriptions: unknown[], listener: (event: unknown) => void, onDisconnect?: (error: Error) => void): Promise<() => void>;
|
|
20
|
+
}
|
|
21
|
+
export class HerdrRpcError extends Error {
|
|
22
|
+
readonly code?: string; readonly requestId: string;
|
|
23
|
+
constructor(message: string, requestId: string, code?: string) { super(message); this.name = "HerdrRpcError"; this.requestId = requestId; this.code = code; }
|
|
24
|
+
}
|
|
25
|
+
export class HerdrTransportError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "HerdrTransportError"; } }
|
|
26
|
+
export interface HerdrForwardedConnection { endpoint: HerdrEndpoint; socketPath: string; client: HerdrRpcClient; forwardRemoteSocket(remotePath: string, name: string): Promise<{ socketPath: string; close(): Promise<void> }>; close(): Promise<void> }
|
|
27
|
+
export function decodeHerdrJsonLine(line: Buffer): unknown { try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown; } catch { throw new Error("Herdr emitted malformed JSON or UTF-8."); } }
|
|
28
|
+
|
|
29
|
+
export function shellQuoteRemote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
30
|
+
export function remoteShellCommand(script: string, args: string[] = []): string { return `sh -c ${shellQuoteRemote(`PATH="${HERDR_REMOTE_PATH}"; export PATH; ${script}`)} sh${args.map((arg) => ` ${shellQuoteRemote(arg)}`).join("")}`; }
|
|
31
|
+
function canonicalHerdrSession(session: string | null | undefined): string | null { return session && session !== "default" ? session : null; }
|
|
32
|
+
function sshEnvCommand(session: string | undefined, command: string): string { const selected = canonicalHerdrSession(session), assignment = selected ? ` HERDR_SESSION=${shellQuoteRemote(selected)}` : ""; return `/usr/bin/env -u HERDR_SOCKET_PATH -u HERDR_SESSION${assignment} ${remoteShellCommand(command)}`; }
|
|
33
|
+
|
|
34
|
+
export function runHerdrRemoteCommand(machine: HerdrMachineReference, command: string, options: { sshBin?: string; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: number } = {}) { return spawnSync(options.sshBin ?? "ssh", [...herdrSshArgs(options.env), machine.target, command], { encoding: "utf8", env: hardenedSshEnv(options.env), timeout: options.timeout ?? 15_000, maxBuffer: options.maxBuffer ?? MAX_DISCOVERY_BYTES, windowsHide: true }); }
|
|
35
|
+
|
|
36
|
+
export function runHerdrRemoteCommandAsync(machine: HerdrMachineReference, command: string, options: { sshBin?: string; env?: NodeJS.ProcessEnv; timeout?: number; maxBuffer?: number } = {}): Promise<{ status: number | null; stdout: string; stderr: string; error?: Error }> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn(options.sshBin ?? "ssh", [...herdrSshArgs(options.env), machine.target, command], { env: hardenedSshEnv(options.env), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
39
|
+
const stdout: Buffer[] = [], stderr: Buffer[] = [], maxBuffer = options.maxBuffer ?? MAX_DISCOVERY_BYTES; let stdoutBytes = 0, stderrBytes = 0, error: Error | undefined, settled = false, escalation: NodeJS.Timeout | undefined;
|
|
40
|
+
const finish = (status: number | null, spawnError = error) => { if (settled) return; settled = true; clearTimeout(timer); if (escalation) clearTimeout(escalation); resolve({ status, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), ...(spawnError ? { error: spawnError } : {}) }); };
|
|
41
|
+
const terminate = () => { child.kill("SIGTERM"); escalation ??= setTimeout(() => { if (!settled) { child.kill("SIGKILL"); finish(child.exitCode ?? null); } }, 1_000); escalation.unref?.(); };
|
|
42
|
+
const collect = (target: Buffer[], stream: "stdout" | "stderr") => (chunk: Buffer) => { const bytes = stream === "stdout" ? (stdoutBytes += chunk.byteLength) : (stderrBytes += chunk.byteLength); if (bytes > maxBuffer) { error ??= new Error(`${stream} maxBuffer length exceeded`); terminate(); } else target.push(chunk); };
|
|
43
|
+
child.stdout.on("data", collect(stdout, "stdout")); child.stderr.on("data", collect(stderr, "stderr"));
|
|
44
|
+
child.once("error", (spawnError) => finish(null, spawnError));
|
|
45
|
+
child.once("close", (status) => finish(status));
|
|
46
|
+
const timer = setTimeout(terminate, options.timeout ?? 15_000); timer.unref?.();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseHerdrEndpoint(value: string, expectedSession?: string): HerdrEndpoint {
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try { parsed = JSON.parse(value) as unknown; } catch { throw new Error("Remote Herdr endpoint discovery returned malformed JSON."); }
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Remote Herdr endpoint discovery returned no endpoint.");
|
|
54
|
+
const p = parsed as Record<string, unknown>;
|
|
55
|
+
if (typeof p.socket !== "string" || !path.posix.isAbsolute(p.socket) || typeof p.version !== "string" || typeof p.protocol !== "number") throw new Error("Remote Herdr endpoint discovery returned incomplete identity.");
|
|
56
|
+
const session = canonicalHerdrSession(typeof p.session === "string" ? p.session : null), expected = canonicalHerdrSession(expectedSession);
|
|
57
|
+
if (expected !== session) throw new Error(`Remote Herdr session identity mismatch: expected ${expected ?? "default"}, received ${session ?? "default"}.`);
|
|
58
|
+
if (p.running !== true || p.compatible !== true || p.endpoint_compatible === false) throw new Error("The selected remote Herdr session is stopped or incompatible.");
|
|
59
|
+
return { socket: p.socket, session, version: p.version, protocol: p.protocol, compatible: true, running: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function discoverHerdrEndpoint(machine: HerdrMachineReference, options: { sshBin?: string; env?: NodeJS.ProcessEnv } = {}): Promise<HerdrEndpoint> {
|
|
63
|
+
const discovery = 'herdr_path=$(command -v herdr) || exit 127; case "$herdr_path" in /*/herdr) ;; *) exit 126;; esac; exec "$herdr_path" status server --json';
|
|
64
|
+
const result = await runHerdrRemoteCommandAsync(machine, sshEnvCommand(machine.session, discovery), { ...options, timeout: 15_000, maxBuffer: MAX_DISCOVERY_BYTES });
|
|
65
|
+
if (result.error) throw new Error(`Remote Herdr discovery failed: ${result.error.message}`);
|
|
66
|
+
if (result.status !== 0) throw new Error(`Remote Herdr discovery failed with code ${result.status}: ${(result.stderr || result.stdout).trim()}`);
|
|
67
|
+
return parseHerdrEndpoint(result.stdout, machine.session);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SocketRpcClient implements HerdrRpcClient {
|
|
71
|
+
readonly #socketPath: string;
|
|
72
|
+
readonly #subscriptionAckTimeoutMs: number;
|
|
73
|
+
#next = 0;
|
|
74
|
+
constructor(socketPath: string, subscriptionAckTimeoutMs = SUBSCRIPTION_ACK_TIMEOUT_MS) { this.#socketPath = socketPath; this.#subscriptionAckTimeoutMs = subscriptionAckTimeoutMs; }
|
|
75
|
+
call<T>(method: string, params: Record<string, unknown> = {}, timeoutMs = 15_000): Promise<T> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const socket = net.createConnection(this.#socketPath); let bytes = 0; let data = Buffer.alloc(0); let finished = false; const id = String(++this.#next);
|
|
78
|
+
const timer = setTimeout(() => finish(new HerdrTransportError(`Herdr RPC '${method}' timed out.`)), timeoutMs); timer.unref?.();
|
|
79
|
+
const finish = (error?: Error, value?: T) => { if (finished) return; finished = true; clearTimeout(timer); socket.destroy(); error ? reject(error) : resolve(value as T); };
|
|
80
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
|
|
81
|
+
socket.on("data", (chunk: Buffer) => {
|
|
82
|
+
bytes += chunk.byteLength; if (bytes > MAX_RPC_BYTES) { finish(new Error("Herdr RPC response exceeded its byte bound.")); return; }
|
|
83
|
+
data = Buffer.concat([data, chunk]); const newline = data.indexOf(10); if (newline < 0) return;
|
|
84
|
+
let frame: unknown; try { frame = decodeHerdrJsonLine(data.subarray(0, newline)); } catch { finish(new Error("Herdr RPC returned malformed JSON or UTF-8.")); return; }
|
|
85
|
+
const record = frame as { id?: unknown; result?: T; error?: { message?: unknown; code?: unknown } };
|
|
86
|
+
if (record.id !== id) { finish(new Error("Herdr RPC response identity mismatch.")); return; }
|
|
87
|
+
if (record.error) finish(new HerdrRpcError(String(record.error.message ?? "Herdr RPC failed."), id, typeof record.error.code === "string" ? record.error.code : undefined)); else finish(undefined, record.result);
|
|
88
|
+
});
|
|
89
|
+
socket.on("error", (error) => finish(new HerdrTransportError(error.message, { cause: error })));
|
|
90
|
+
socket.on("close", () => finish(new HerdrTransportError(`Herdr RPC '${method}' ended before a response.`)));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async subscribe(subscriptions: unknown[], listener: (event: unknown) => void, onDisconnect?: (error: Error) => void): Promise<() => void> {
|
|
94
|
+
const socket = net.createConnection(this.#socketPath); const id = String(++this.#next); let buffer = Buffer.alloc(0), bytes = 0, acknowledged = false, intentional = false, notified = false, settled = false;
|
|
95
|
+
let resolveAck!: () => void, rejectAck!: (error: Error) => void;
|
|
96
|
+
const acknowledgement = new Promise<void>((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
97
|
+
const failHandshake = (error: Error) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); rejectAck(error); };
|
|
98
|
+
const disconnect = (error: Error) => { if (intentional || notified) return; notified = true; if (!acknowledged) failHandshake(error); else onDisconnect?.(error); };
|
|
99
|
+
const timer = setTimeout(() => failHandshake(new Error("Herdr subscription acknowledgement timed out.")), this.#subscriptionAckTimeoutMs); timer.unref?.();
|
|
100
|
+
socket.on("data", (chunk: Buffer) => { bytes += chunk.byteLength; if (bytes > (acknowledged ? MAX_RPC_BYTES * 32 : MAX_RPC_BYTES)) { disconnect(new Error("Herdr subscription exceeded its byte bound.")); socket.destroy(); return; } buffer = Buffer.concat([buffer, chunk]); for (;;) { const i = buffer.indexOf(10); if (i < 0) break; const line = buffer.subarray(0, i); buffer = buffer.subarray(i + 1); let value: unknown; try { value = decodeHerdrJsonLine(line); } catch { disconnect(new Error("Herdr subscription emitted malformed JSON or UTF-8.")); socket.destroy(); return; } const record = value as { id?: unknown; result?: { type?: unknown }; error?: { message?: unknown; code?: unknown } }; if (!acknowledged) { if (record.id === id) { if (record.error) { failHandshake(new HerdrRpcError(String(record.error.message ?? "Herdr subscription failed."), id, typeof record.error.code === "string" ? record.error.code : undefined)); return; } if (record.result?.type !== "subscription_started") { failHandshake(new Error("Herdr subscription returned an invalid acknowledgement.")); return; } acknowledged = true; settled = true; clearTimeout(timer); resolveAck(); continue; } if (record.id === "" && record.error && record.error.code === "invalid_request") { failHandshake(new HerdrRpcError(String(record.error.message ?? "Herdr subscription request was invalid."), "", "invalid_request")); return; } failHandshake(new Error("Herdr subscription emitted an event before acknowledgement.")); return; } if (record.error) { disconnect(new HerdrRpcError(String(record.error.message ?? "Herdr subscription failed."), typeof record.id === "string" ? record.id : "", typeof record.error.code === "string" ? record.error.code : undefined)); socket.destroy(); return; } listener(value); } });
|
|
101
|
+
socket.on("error", (error) => disconnect(error));
|
|
102
|
+
socket.on("close", () => disconnect(new Error("Herdr subscription connection was lost.")));
|
|
103
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ id, method: "events.subscribe", params: { subscriptions } })}\n`));
|
|
104
|
+
await acknowledgement;
|
|
105
|
+
return () => { intentional = true; clearTimeout(timer); socket.destroy(); };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function waitForSocket(socketPath: string, child: ChildProcess): Promise<void> {
|
|
110
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
111
|
+
if (child.exitCode !== null) throw new Error(`SSH StreamLocal forwarding exited with code ${child.exitCode}.`);
|
|
112
|
+
try { const stat = fs.lstatSync(socketPath); if (stat.isSocket()) { fs.chmodSync(socketPath, 0o600); return; } } catch {}
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
114
|
+
}
|
|
115
|
+
throw new Error("SSH StreamLocal forwarding did not create its local socket.");
|
|
116
|
+
}
|
|
117
|
+
async function terminateChild(child: ChildProcess): Promise<void> { const wait = (ms: number) => child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : Promise.race([new Promise<void>((resolve) => child.once("exit", () => resolve())), new Promise<void>((resolve) => setTimeout(resolve, ms))]); if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); await wait(1_000); if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await wait(1_000); } }
|
|
118
|
+
|
|
119
|
+
export async function connectHerdrMachine(machine: HerdrMachineReference, options: { sshBin?: string; env?: NodeJS.ProcessEnv } = {}): Promise<HerdrForwardedConnection> {
|
|
120
|
+
if (process.platform === "win32") throw new Error("Pane-native Herdr placement requires OpenSSH StreamLocal forwarding and is not supported on Windows.");
|
|
121
|
+
const endpoint = await discoverHerdrEndpoint(machine, options);
|
|
122
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-herdr-")); fs.chmodSync(dir, 0o700);
|
|
123
|
+
const children = new Set<ChildProcess>();
|
|
124
|
+
const forward = async (remotePath: string, name: string) => {
|
|
125
|
+
if (!path.posix.isAbsolute(remotePath)) throw new Error("Remote socket path must be absolute.");
|
|
126
|
+
const local = path.join(dir, `${name}.sock`);
|
|
127
|
+
const child = spawn(options.sshBin ?? "ssh", [...herdrSshArgs(options.env), "-N", "-o", "StreamLocalBindUnlink=yes", "-L", `${local}:${remotePath}`, machine.target], { env: hardenedSshEnv(options.env), stdio: "ignore", windowsHide: true }); children.add(child);
|
|
128
|
+
const stop = async () => { await terminateChild(child); children.delete(child); try { fs.rmSync(local, { force: true }); } catch {} };
|
|
129
|
+
try { await waitForSocket(local, child); } catch (error) { await stop(); throw error; }
|
|
130
|
+
return { socketPath: local, close: stop };
|
|
131
|
+
};
|
|
132
|
+
const primary = await forward(endpoint.socket, "herdr").catch((error) => { fs.rmSync(dir, { recursive: true, force: true }); throw error; });
|
|
133
|
+
return { endpoint, socketPath: primary.socketPath, client: new SocketRpcClient(primary.socketPath), forwardRemoteSocket: forward, close: async () => { await Promise.all([...children].map(terminateChild)); children.clear(); fs.rmSync(dir, { recursive: true, force: true }); } };
|
|
134
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
4
|
+
import type { HerdrMachineReference, HerdrRemoteGitStatus } from "../../shared/types.ts";
|
|
5
|
+
import { connectHerdrMachine, HerdrRpcError, HerdrTransportError, remoteShellCommand, shellQuoteRemote, type HerdrForwardedConnection } from "./herdr-connection.ts";
|
|
6
|
+
import { HerdrPlacedRunOwner, herdrStatusSubscriptions, ownsHerdrPane, parseHerdrAgent, reconcileHerdrPlacedRun, type HerdrReconnectCandidate, type HerdrRunIdentity } from "./herdr-placed-run.ts";
|
|
7
|
+
|
|
8
|
+
export type HerdrExternalAdapterId = "claude-code" | "claude-code-writer" | "cursor-agent" | "cursor-agent-writer" | "codex-exec" | "codex-exec-writer";
|
|
9
|
+
export type HerdrExternalKind = "claude" | "cursor" | "codex";
|
|
10
|
+
export interface HerdrExternalCapabilities { stop: true; steer: false; resume: false; supervisor: "unsupported" }
|
|
11
|
+
export interface HerdrExternalLaunch { adapter: HerdrExternalAdapterId; kind: HerdrExternalKind; nativeSessionId: string; args: string[]; capabilities: HerdrExternalCapabilities }
|
|
12
|
+
export interface HerdrExternalResult {
|
|
13
|
+
protocol: 1; adapter: HerdrExternalAdapterId; runId: string; requestId: string; nativeSessionId: "unverified"; nativeTurnId: "unverified";
|
|
14
|
+
outcome: "partial"; output: string; initialGit?: HerdrRemoteGitStatus; finalGit?: HerdrRemoteGitStatus;
|
|
15
|
+
settlement?: { verification: "best-effort/unverified"; evidence: "sanitized-terminal-snapshots" };
|
|
16
|
+
}
|
|
17
|
+
export interface HerdrExternalEvidenceInput { runId: string; requestId: string; task: string; cwd: string; nativeSessionId: string; requestedModel?: string; initialGit?: unknown; finalGit?: unknown }
|
|
18
|
+
export interface HerdrExternalCommandEvidence { binary: string; args: readonly string[]; status: number; stdout: string; stderr: string }
|
|
19
|
+
export interface HerdrExternalPreflightEvidence { version: HerdrExternalCommandEvidence; help: HerdrExternalCommandEvidence }
|
|
20
|
+
export interface HerdrExternalAdapter {
|
|
21
|
+
readonly id: HerdrExternalAdapterId; readonly kind: HerdrExternalKind;
|
|
22
|
+
preflight(evidence: HerdrExternalPreflightEvidence): void;
|
|
23
|
+
launch(input: Omit<Parameters<typeof createHerdrExternalAdapterLaunch>[0], "adapter">): HerdrExternalLaunch;
|
|
24
|
+
normalize(input: HerdrExternalEvidenceInput, evidence: Buffer | string): HerdrExternalResult;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CAPABILITIES: HerdrExternalCapabilities = { stop: true, steer: false, resume: false, supervisor: "unsupported" };
|
|
28
|
+
const MAX_EVIDENCE_BYTES = 4 * 1024 * 1024, MAX_OUTPUT_BYTES = 1024 * 1024, MAX_PREFLIGHT_BYTES = 64 * 1024, MAX_GIT_BYTES = 2048;
|
|
29
|
+
const CODEX_OUTPUT_BYTES = 64 * 1024, EXTERNAL_STARTUP_TIMEOUT_MS = 45_000, EXTERNAL_STARTUP_BACKOFF_AFTER_MS = 15_000, EXTERNAL_STARTUP_SLOW_POLL_MS = 2_500, CODEX_STARTUP_GRACE_MS = 15_000, CODEX_QUIET_MS = 8_000, CODEX_POLL_MS = 1_000, CODEX_DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
30
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, SHA = /^[0-9a-f]{4,64}$/iu;
|
|
31
|
+
|
|
32
|
+
function boundedString(value: unknown, label: string, maximum = MAX_OUTPUT_BYTES): string { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is missing.`); if (Buffer.byteLength(value) > maximum) throw new Error(`${label} exceeded its byte bound.`); return value; }
|
|
33
|
+
function exactUuid(value: unknown, label: string): string { const result = boundedString(value, label, 64); if (!UUID.test(result)) throw new Error(`${label} is not a UUID.`); return result; }
|
|
34
|
+
function normalizeGit(value: unknown, label: string): HerdrRemoteGitStatus | undefined {
|
|
35
|
+
if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.byteLength(JSON.stringify(value)) > MAX_GIT_BYTES) throw new Error(`${label} is malformed or oversized.`);
|
|
36
|
+
const data = value as Record<string, unknown>, unknown = Object.keys(data).filter((key) => key !== "head" && key !== "branch" && key !== "dirty"); if (unknown.length) throw new Error(`${label} has unsupported keys.`);
|
|
37
|
+
if (data.head !== undefined && (typeof data.head !== "string" || !SHA.test(data.head))) throw new Error(`${label}.head is invalid.`);
|
|
38
|
+
if (data.branch !== undefined && (typeof data.branch !== "string" || !data.branch.trim() || Buffer.byteLength(data.branch) > 256 || /[\x00-\x1f\x7f]/u.test(data.branch))) throw new Error(`${label}.branch is invalid.`);
|
|
39
|
+
if (data.dirty !== undefined && typeof data.dirty !== "boolean") throw new Error(`${label}.dirty is invalid.`);
|
|
40
|
+
return { ...(data.head !== undefined ? { head: data.head } : {}), ...(data.branch !== undefined ? { branch: data.branch } : {}), ...(data.dirty !== undefined ? { dirty: data.dirty } : {}) };
|
|
41
|
+
}
|
|
42
|
+
function resultGit(input: HerdrExternalEvidenceInput) { const initialGit = normalizeGit(input.initialGit, "initial Git evidence"), finalGit = normalizeGit(input.finalGit, "final Git evidence"); return { ...(initialGit ? { initialGit } : {}), ...(finalGit ? { finalGit } : {}) }; }
|
|
43
|
+
function atLeast(actual: [number, number, number], floor: [number, number, number]): boolean { return actual[0] > floor[0] || actual[0] === floor[0] && (actual[1] > floor[1] || actual[1] === floor[1] && actual[2] >= floor[2]); }
|
|
44
|
+
function requireHelp(help: string, values: string[], label: string): void { if (!/^(?:Usage:|Claude Code|Codex|Start the Cursor Agent)/u.test(help.trimStart())) throw new Error(`${label} help response has an unsupported header.`); for (const value of values) { const matches = help.match(new RegExp(`(^|\\s)${value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?=\\s|[=,.;:]|$)`, "gmu")); if (!matches?.length) throw new Error(`${label} help does not document required interactive option ${JSON.stringify(value)}.`); } }
|
|
45
|
+
function validateCommand(record: HerdrExternalCommandEvidence, binary: string, args: readonly string[], label: string): void {
|
|
46
|
+
if (!path.posix.isAbsolute(record.binary) || path.posix.basename(record.binary) !== binary || record.args.length !== args.length || record.args.some((value, index) => value !== args[index])) throw new Error(`${label} preflight command identity is invalid.`);
|
|
47
|
+
if (record.status !== 0) throw new Error(`${label} preflight exited ${record.status}: ${record.stderr.slice(0, 512)}`); for (const [name, value] of [["stdout", record.stdout], ["stderr", record.stderr]] as const) if (typeof value !== "string" || Buffer.byteLength(value) > MAX_PREFLIGHT_BYTES) throw new Error(`${label} preflight ${name} exceeded its bound.`);
|
|
48
|
+
}
|
|
49
|
+
function adapterKind(adapter: HerdrExternalAdapterId): HerdrExternalKind { return adapter.startsWith("claude") ? "claude" : adapter.startsWith("cursor") ? "cursor" : "codex"; }
|
|
50
|
+
function adapterBinary(adapter: HerdrExternalAdapterId): string { return adapterKind(adapter) === "claude" ? "claude" : adapterKind(adapter) === "cursor" ? "cursor-agent" : "codex"; }
|
|
51
|
+
function expectedStartedArgv(launch: HerdrExternalLaunch): string[] { return [launch.kind, ...launch.args]; }
|
|
52
|
+
function sameStrings(actual: unknown, expected: readonly string[]): actual is string[] { return Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index]); }
|
|
53
|
+
function validateStartedArgv(launch: HerdrExternalLaunch, actual: unknown): string[] { const expected = expectedStartedArgv(launch); if (!sameStrings(actual, expected)) throw new Error(`Herdr started ${launch.kind} with unexpected native argv.`); return [...actual]; }
|
|
54
|
+
function canonicalPreflightBinary(adapter: HerdrExternalAdapterId, evidence: HerdrExternalPreflightEvidence): string { const binary = evidence.version.binary; if (!path.posix.isAbsolute(binary) || evidence.help.binary !== binary || path.posix.basename(binary) !== adapterBinary(adapter)) throw new Error("External preflight canonical binary identity is inconsistent."); return binary; }
|
|
55
|
+
function matchesExternalProcess(entry: Record<string, unknown>, cwd: string, canonicalArgv0: string): boolean { const name = entry.name; return typeof entry.pid === "number" && typeof name === "string" && Boolean(name.trim()) && Buffer.byteLength(name) <= 128 && !/[\x00-\x1f\x7f]/u.test(name) && entry.argv0 === canonicalArgv0 && entry.cwd === cwd; }
|
|
56
|
+
export function reconcileHerdrExternalRun(identity: HerdrRunIdentity, candidate: HerdrReconnectCandidate) { const check = reconcileHerdrPlacedRun(identity, candidate, () => ({ ok: true, cursor: 0 })); if (!check.ok) return check; if (check.paneId !== identity.paneId) return { ok: false as const, reason: "External reconnect refused pane identity drift." }; return check; }
|
|
57
|
+
|
|
58
|
+
export function validateHerdrExternalPreflight(adapter: HerdrExternalAdapterId, evidence: HerdrExternalPreflightEvidence): void {
|
|
59
|
+
const kind = adapterKind(adapter), binary = adapterBinary(adapter); validateCommand(evidence.version, binary, ["--version"], binary); validateCommand(evidence.help, binary, ["--help"], binary);
|
|
60
|
+
canonicalPreflightBinary(adapter, evidence);
|
|
61
|
+
const version = evidence.version.stdout.trim(), match = kind === "claude" ? version.match(/^(\d+)\.(\d+)\.(\d+) \(Claude Code\)$/u) : kind === "codex" ? version.match(/^codex-cli (\d+)\.(\d+)\.(\d+)$/u) : version.match(/^(\d{4})\.(\d{2})\.(\d{2})-[0-9a-f]+$/u); if (!match) throw new Error(`Unsupported ${binary} version response: ${JSON.stringify(version)}.`);
|
|
62
|
+
const parsed: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])]; const floor: [number, number, number] = kind === "claude" ? [2, 1, 269] : kind === "codex" ? [0, 154, 0] : [2026, 9, 10]; if (!atLeast(parsed, floor)) throw new Error(`Remote ${binary} is below the tested pane-native capability floor.`);
|
|
63
|
+
requireHelp(evidence.help.stdout, kind === "claude" ? ["--session-id", "--restricted", "--permission-mode", "--tools", "--strict-mcp-config", "--mcp-config", "--disable-slash-commands", "--no-chrome"] : kind === "cursor" ? ["--mode", "--sandbox", "--workspace", "--trust"] : ["--sandbox", "--ask-for-approval", "--no-alt-screen"], binary);
|
|
64
|
+
}
|
|
65
|
+
function commandEvidence(binary: string, args: readonly string[], result: ReturnType<HerdrPlacedRunOwner["runRemote"]>): HerdrExternalCommandEvidence { return { binary, args, status: result.status ?? -1, stdout: String(result.stdout), stderr: String(result.stderr) }; }
|
|
66
|
+
export function runHerdrExternalPreflight(owner: HerdrPlacedRunOwner, adapter: HerdrExternalAdapterId): HerdrExternalPreflightEvidence {
|
|
67
|
+
const name = adapterBinary(adapter), located = owner.runRemote(remoteShellCommand(`command -v ${name}`), { timeout: 10_000, maxBuffer: 4096 }), binary = String(located.stdout).trim(); if (located.status !== 0 || !path.posix.isAbsolute(binary) || path.posix.basename(binary) !== name || binary.includes("\n")) throw new Error(`Remote canonical ${name} binary could not be resolved.`);
|
|
68
|
+
const invoke = (args: readonly string[]) => commandEvidence(binary, args, owner.runRemote(remoteShellCommand(adapterKind(adapter) === "cursor" ? `exec /usr/bin/env AGENT_CLI_CREDENTIAL_STORE=file ${shellQuoteRemote(binary)} "$@"` : `exec ${shellQuoteRemote(binary)} "$@"`, [...args]), { timeout: 10_000, maxBuffer: MAX_PREFLIGHT_BYTES })); const evidence = { version: invoke(["--version"]), help: invoke(["--help"]) }; validateHerdrExternalPreflight(adapter, evidence); return evidence;
|
|
69
|
+
}
|
|
70
|
+
export function createHerdrExternalAdapterLaunch(input: { adapter: HerdrExternalAdapterId; remoteRuntimeDir: string; cwd?: string; nativeSessionId?: string; model?: string; environment?: Readonly<Record<string, string>>; resources?: Readonly<Record<string, unknown>> }): HerdrExternalLaunch {
|
|
71
|
+
if (!path.posix.isAbsolute(input.remoteRuntimeDir) || path.posix.basename(input.remoteRuntimeDir).length < 8) throw new Error("External adapter requires the accepted run-private remote runtime root."); if (input.environment && Object.keys(input.environment).length) throw new Error("Pane-native external adapters reject caller environment bindings; credentials remain machine-owned."); if (input.resources && Object.keys(input.resources).length) throw new Error("Pane-native external adapters do not accept expanded prompts, local paths, callbacks, tools, skills, or MCP bindings."); if (input.model !== undefined) throw new Error("Pane-native M2b adapters use the remote managed model registry and reject model override.");
|
|
72
|
+
const nativeSessionId = input.nativeSessionId ?? randomUUID(); exactUuid(nativeSessionId, "External adapter native session identity"); const kind = adapterKind(input.adapter);
|
|
73
|
+
if (kind === "claude") {
|
|
74
|
+
const writer = input.adapter === "claude-code-writer";
|
|
75
|
+
return { adapter: input.adapter, kind, nativeSessionId, capabilities: CAPABILITIES, args: ["--session-id", nativeSessionId, "--restricted", "--permission-mode", writer ? "acceptEdits" : "plan", "--tools", writer ? "Read,Write,Edit,Glob,Grep" : "", "--strict-mcp-config", "--mcp-config", "{\"mcpServers\":{}}", "--disable-slash-commands", "--no-chrome"] };
|
|
76
|
+
}
|
|
77
|
+
if (kind === "cursor") { if (!input.cwd || !path.posix.isAbsolute(input.cwd)) throw new Error("Pane-native Cursor requires the exact remote-absolute owned workspace."); return { adapter: input.adapter, kind, nativeSessionId, capabilities: CAPABILITIES, args: [...(input.adapter === "cursor-agent" ? ["--mode", "ask"] : []), "--sandbox", "enabled", "--workspace", input.cwd] }; }
|
|
78
|
+
return { adapter: input.adapter, kind, nativeSessionId, capabilities: CAPABILITIES, args: ["--sandbox", input.adapter === "codex-exec-writer" ? "workspace-write" : "read-only", "--ask-for-approval", "never", "--no-alt-screen"] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface HerdrCodexSnapshot { at: number; workspaceId: string; paneId: string; terminalId: string; pid: number; text: string; source?: "visible" | "recent_unwrapped" }
|
|
82
|
+
export interface HerdrCodexMonitorClock { now(): number; wait(ms: number): Promise<void> }
|
|
83
|
+
export class HerdrExternalNeedsAttentionError extends Error { readonly code = "HERDR_EXTERNAL_NEEDS_ATTENTION"; readonly identity: { machineId: string; workspaceId: string; paneId: string; terminalId: string }; constructor(message: string, identity: { machineId: string; workspaceId: string; paneId: string; terminalId: string }) { super(message); this.identity = identity; } }
|
|
84
|
+
function sanitizeTerminal(input: string): string { const clean = stripVTControlCharacters(input).replace(/\r/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").replace(/[ \t]+$/gmu, "").trim(); const bytes = Buffer.from(clean); return bytes.length <= CODEX_OUTPUT_BYTES ? clean : bytes.subarray(bytes.length - CODEX_OUTPUT_BYTES).toString("utf8").replace(/^\uFFFD/u, ""); }
|
|
85
|
+
function codexAttention(text: string): boolean { return /(?:trust this|do you trust the contents of this directory|approval required|approve\?|review (?:hook|action)|action required|permission required|error:|fatal:)/iu.test(text); }
|
|
86
|
+
function codexWorking(text: string): boolean { return /(?:working|thinking|running|esc to interrupt|ctrl\+c to interrupt|•\s*[^\n]+…)/iu.test(text); }
|
|
87
|
+
function codexReady(text: string): boolean { return /(?:›|>)\s*(?:Ask|Describe|Type)\b|(?:Ask|Describe) anything/iu.test(text); }
|
|
88
|
+
function codexSubmissionEnd(text: string, task: string): number {
|
|
89
|
+
const lines = text.split("\n").map((line) => line.trimEnd()), taskLines = task.split(/\r?\n/u).map((line) => line.trimEnd());
|
|
90
|
+
for (let index = 0; index < lines.length; index++) {
|
|
91
|
+
const first = lines[index]!.match(/^[›>]\s?(.*)$/u); if (!first || first[1] !== taskLines[0]) continue;
|
|
92
|
+
if (taskLines.slice(1).every((line, offset) => lines[index + offset + 1] === line)) return index + taskLines.length;
|
|
93
|
+
}
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
96
|
+
function codexAssistantOutput(text: string, task: string): string {
|
|
97
|
+
const lines = text.split("\n").map((line) => line.trimEnd()), submittedEnd = codexSubmissionEnd(text, task); if (submittedEnd < 0) return ""; const after = lines.slice(submittedEnd).filter((line) => line.trim() && !codexReady(line) && !/^tokens? used\b/iu.test(line)); return after.join("\n").trim();
|
|
98
|
+
}
|
|
99
|
+
export async function monitorHerdrCodex(input: { machineId: string; task: string; preSubmitText: string; identity: Omit<HerdrCodexSnapshot, "at" | "text" | "source">; snapshot(): Promise<HerdrCodexSnapshot>; stop(): Promise<void>; timeoutMs?: number; clock?: HerdrCodexMonitorClock }): Promise<{ output: string; snapshots: number }> {
|
|
100
|
+
const clock = input.clock ?? { now: () => Date.now(), wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) }, started = clock.now(), deadline = started + (input.timeoutMs ?? CODEX_DEFAULT_TIMEOUT_MS), expected = input.identity, before = sanitizeTerminal(input.preSubmitText); if (codexSubmissionEnd(before, input.task) >= 0) throw new HerdrExternalNeedsAttentionError("Codex pre-submit screen already contained the task; correlation is ambiguous.", { machineId: input.machineId, workspaceId: expected.workspaceId, paneId: expected.paneId, terminalId: expected.terminalId }); let previous = before, previousSource: HerdrCodexSnapshot["source"], lastChange = started, meaningful = false, stable = 0, reads = 0;
|
|
101
|
+
const attention = (message: string) => new HerdrExternalNeedsAttentionError(message, { machineId: input.machineId, workspaceId: expected.workspaceId, paneId: expected.paneId, terminalId: expected.terminalId });
|
|
102
|
+
while (clock.now() <= deadline) { const sample = await input.snapshot(); reads++; if (sample.workspaceId !== expected.workspaceId || sample.paneId !== expected.paneId || sample.terminalId !== expected.terminalId || sample.pid !== expected.pid) throw attention("Placed Codex identity drifted; truthful pane retained for inspection."); const text = sanitizeTerminal(sample.text), sourceChanged = previousSource !== undefined && sample.source !== previousSource; if (codexAttention(text)) throw attention("Placed Codex requires attention; truthful pane retained for inspection."); if (text !== previous || sourceChanged) { if (text !== previous) meaningful = true; previous = text; previousSource = sample.source; lastChange = clock.now(); stable = 0; } else { previousSource = sample.source; stable++; }
|
|
103
|
+
const output = codexAssistantOutput(text, input.task), quiet = clock.now() - lastChange >= CODEX_QUIET_MS; if (sample.source === "recent_unwrapped" && clock.now() - started >= CODEX_STARTUP_GRACE_MS && meaningful && quiet && stable >= 1 && !codexWorking(text) && codexReady(text) && output) { await input.stop(); return { output: sanitizeTerminal(output), snapshots: reads }; }
|
|
104
|
+
await clock.wait(CODEX_POLL_MS);
|
|
105
|
+
}
|
|
106
|
+
throw attention("Placed Codex settlement remained ambiguous until timeout; truthful pane retained for inspection.");
|
|
107
|
+
}
|
|
108
|
+
function normalizePlacedTerminal(input: HerdrExternalEvidenceInput, output: string, adapter: HerdrExternalAdapterId): HerdrExternalResult {
|
|
109
|
+
if (input.requestedModel !== undefined) throw new Error("Pane-native external adapters use the remote managed model registry and reject model override.");
|
|
110
|
+
return { protocol: 1, adapter, runId: input.runId, requestId: input.requestId, nativeSessionId: "unverified", nativeTurnId: "unverified", outcome: "partial", output: `[best-effort/unverified]\n${boundedString(sanitizeTerminal(output), "sanitized terminal output", CODEX_OUTPUT_BYTES)}`, settlement: { verification: "best-effort/unverified", evidence: "sanitized-terminal-snapshots" }, ...resultGit(input) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createHerdrExternalAdapter(id: HerdrExternalAdapterId): HerdrExternalAdapter { const kind = adapterKind(id); return { id, kind, preflight: (evidence) => validateHerdrExternalPreflight(id, evidence), launch: (input) => createHerdrExternalAdapterLaunch({ adapter: id, ...input }), normalize: (input, evidence) => normalizePlacedTerminal(input, Buffer.isBuffer(evidence) ? evidence.toString("utf8") : evidence, id) }; }
|
|
114
|
+
|
|
115
|
+
function unwrap<T extends Record<string, unknown>>(value: unknown, type: string, key: string): T { if (!value || typeof value !== "object" || (value as Record<string, unknown>).type !== type || !(key in (value as Record<string, unknown>))) throw new Error(`Herdr returned invalid ${type} evidence.`); return (value as Record<string, T>)[key]!; }
|
|
116
|
+
export class HerdrExternalSession {
|
|
117
|
+
readonly owner: HerdrPlacedRunOwner; readonly launch: HerdrExternalLaunch; readonly preflight: HerdrExternalPreflightEvidence; readonly #adapter: HerdrExternalAdapter; readonly #connect: typeof connectHerdrMachine; #settled = false; #prompted = false; #nativePid?: number; #cursorWorkspace?: { realpath: string; device: number; inode: number; uid: number }; #reconnectPromise?: Promise<void>; #disposePromise?: Promise<void>;
|
|
118
|
+
#reconnectCandidatesRemaining = 3; #reconnectDeadline?: number; #reconnectFailure?: Error;
|
|
119
|
+
constructor(owner: HerdrPlacedRunOwner, launch: HerdrExternalLaunch, preflight: HerdrExternalPreflightEvidence, connect: typeof connectHerdrMachine = connectHerdrMachine) { this.owner = owner; this.launch = launch; this.preflight = preflight; this.#adapter = createHerdrExternalAdapter(launch.adapter); this.#connect = connect; }
|
|
120
|
+
settle(input: HerdrExternalEvidenceInput, evidence: Buffer | string): HerdrExternalResult { if (this.#settled || this.#disposePromise) throw new Error("Pane-native external run evidence already settled or disposed."); if (input.nativeSessionId !== this.launch.nativeSessionId) throw new Error("Pane-native external evidence session identity changed."); const result = this.#adapter.normalize(input, evidence); this.#settled = true; return result; }
|
|
121
|
+
async promptAndSettle(input: HerdrExternalEvidenceInput, options: { timeoutMs?: number; clock?: HerdrCodexMonitorClock; snapshot?: () => Promise<HerdrCodexSnapshot> } = {}): Promise<HerdrExternalResult> {
|
|
122
|
+
if (this.#prompted || this.#settled || this.#disposePromise) throw new Error("Pane-native external task was already submitted, settled, or disposed."); this.#prompted = true; if (!this.owner.agentName || !this.owner.owned || !this.owner.terminalId) throw new Error("Pane-native external owner identity is incomplete.");
|
|
123
|
+
let preSubmitText = "";
|
|
124
|
+
if (this.launch.kind === "cursor") { try { this.#cursorWorkspace = this.#proveCursorWorkspace(); } catch (error) { throw this.#needsAttention(`Placed Cursor requires attention before input: ${error instanceof Error ? error.message : String(error)}; truthful pane retained for inspection.`); } }
|
|
125
|
+
if (this.launch.kind === "cursor" || this.launch.kind === "codex") { const ready = await this.#waitForStartup(options.clock); preSubmitText = ready.text; this.#nativePid = ready.pid; }
|
|
126
|
+
else this.#nativePid = (await this.#exactProcess()).pid;
|
|
127
|
+
const waitTimeout = Math.min(Math.max(1, options.timeoutMs ?? 90_000), CODEX_DEFAULT_TIMEOUT_MS), params: Record<string, unknown> = { target: this.owner.agentName, text: input.task };
|
|
128
|
+
if (this.launch.kind !== "codex") params.wait = { until: ["idle", "done", "blocked"], timeout_ms: waitTimeout };
|
|
129
|
+
let response: unknown, recovered = false; try { response = await this.owner.connection.client.call("agent.prompt", params, waitTimeout + 5_000); } catch (error) { if (error instanceof HerdrRpcError || !(error instanceof HerdrTransportError)) throw error; if (this.owner.snapshot.connection !== "unknown") throw this.#needsAttention(`Placed external prompt settlement is unresolved: ${error.message}; truthful pane retained for inspection.`); await this.reconnect(); response = await this.owner.connection.client.call("agent.get", { target: this.owner.agentName }); recovered = true; }
|
|
130
|
+
const prompted = parseHerdrAgent(response, recovered ? "agent_info" : "agent_prompted"); if (!ownsHerdrPane(prompted, this.owner.terminalId, this.owner.owned.paneId)) throw new Error("Herdr prompt settlement identity changed.");
|
|
131
|
+
if (this.launch.kind !== "codex" && prompted.agent_status !== "idle" && prompted.agent_status !== "done") throw this.#needsAttention(`Herdr external prompt did not settle safely (status ${String(prompted.agent_status)}); truthful pane retained for inspection.`);
|
|
132
|
+
await this.#exactProcessAfterReconnect(this.#nativePid);
|
|
133
|
+
if (this.launch.kind === "cursor") this.#assertSameCursorWorkspace();
|
|
134
|
+
if (this.launch.kind !== "codex") {
|
|
135
|
+
const terminal = await this.#readSettledPane();
|
|
136
|
+
this.#settled = true;
|
|
137
|
+
return normalizePlacedTerminal(input, terminal, this.launch.adapter);
|
|
138
|
+
}
|
|
139
|
+
const expected = { workspaceId: this.owner.owned.workspaceId, paneId: this.owner.owned.paneId, terminalId: this.owner.terminalId, pid: this.#nativePid }, snapshot = options.snapshot ?? (() => this.#codexSnapshot(this.#nativePid!)); const monitored = await monitorHerdrCodex({ machineId: this.owner.machine.id, task: input.task, preSubmitText, identity: expected, snapshot, stop: () => this.owner.cleanup(true), timeoutMs: waitTimeout, clock: options.clock }); this.#settled = true; return normalizePlacedTerminal(input, monitored.output, this.launch.adapter);
|
|
140
|
+
}
|
|
141
|
+
#needsAttention(message: string): HerdrExternalNeedsAttentionError { return new HerdrExternalNeedsAttentionError(message, { machineId: this.owner.machine.id, workspaceId: this.owner.owned!.workspaceId, paneId: this.owner.owned!.paneId, terminalId: this.owner.terminalId! }); }
|
|
142
|
+
#proveCursorWorkspace(): { realpath: string; device: number; inode: number; uid: number } { const script = `const fs=require("node:fs"),p=process.argv[1],s=fs.lstatSync(p),r=fs.realpathSync(p),u=process.getuid();if(!s.isDirectory()||s.isSymbolicLink()||r!==p||s.uid!==u||(s.mode&18)!==0)process.exit(65);process.stdout.write(JSON.stringify({realpath:r,device:s.dev,inode:s.ino,uid:s.uid}))`, raw = this.#remoteEvidence(script, [this.owner.machine.cwd]); let value: unknown; try { value = JSON.parse(raw); } catch { throw new Error("Cursor workspace identity evidence is malformed."); } const data = value as Record<string, unknown>; if (!data || data.realpath !== this.owner.machine.cwd || !Number.isSafeInteger(data.device) || !Number.isSafeInteger(data.inode) || !Number.isSafeInteger(data.uid)) throw new Error("Cursor workspace is not the exact canonical owned safe directory."); return data as unknown as { realpath: string; device: number; inode: number; uid: number }; }
|
|
143
|
+
#assertSameCursorWorkspace(): void { const current = this.#proveCursorWorkspace(), expected = this.#cursorWorkspace; if (!expected || current.realpath !== expected.realpath || current.device !== expected.device || current.inode !== expected.inode || current.uid !== expected.uid) throw new Error("Cursor workspace identity changed before settlement."); }
|
|
144
|
+
async #readCurrentPane(): Promise<string> { const value = await this.owner.connection.client.call("pane.read", { pane_id: this.owner.owned!.paneId, source: "visible", strip_ansi: true }), read = unwrap<Record<string, unknown>>(value, "pane_read", "read"); if (read.pane_id !== this.owner.owned!.paneId) throw new Error("External pane read identity changed."); return String(read.text ?? ""); }
|
|
145
|
+
async #readSettledPane(): Promise<string> { const value = await this.owner.connection.client.call("pane.read", { pane_id: this.owner.owned!.paneId, source: "recent_unwrapped", lines: 400, strip_ansi: true }), read = unwrap<Record<string, unknown>>(value, "pane_read", "read"); if (read.pane_id !== this.owner.owned!.paneId || read.source !== "recent_unwrapped") throw new Error("External terminal evidence identity or source changed."); return String(read.text ?? ""); }
|
|
146
|
+
#cursorReady(text: string): boolean { if (/trust (?:this|workspace|folder)|untrusted/iu.test(text) || codexAttention(text)) throw this.#needsAttention("Placed Cursor requires trust or other attention before input; truthful pane retained for inspection."); return /(?:Ask|Plan|Agent)\b/iu.test(text); }
|
|
147
|
+
async #processProjection(): Promise<{ processes: Record<string, unknown>[]; canonical: Record<string, unknown>[] }> { validateStartedArgv(this.launch, this.owner.startedArgv); const canonicalArgv0 = path.posix.basename(canonicalPreflightBinary(this.launch.adapter, this.preflight)), value = await this.owner.connection.client.call("pane.process_info", { pane_id: this.owner.owned!.paneId }), info = unwrap<Record<string, unknown>>(value, "pane_process_info", "process_info"); if (info.pane_id !== this.owner.owned!.paneId) throw new Error("External process evidence pane identity changed."); if (!Array.isArray(info.foreground_processes)) throw new Error("External process evidence foreground process projection is malformed."); const processes = info.foreground_processes as Record<string, unknown>[]; return { processes, canonical: processes.filter((entry) => matchesExternalProcess(entry, this.owner.machine.cwd, canonicalArgv0)) }; }
|
|
148
|
+
async #exactProcess(requiredPid?: number): Promise<{ pid: number }> { const projection = await this.#processProjection(), canonical = projection.canonical[0]; if (projection.canonical.length !== 1 || requiredPid !== undefined && canonical?.pid !== requiredPid) throw new Error(`Could not prove exactly one canonical ${path.posix.basename(this.preflight.version.binary)} native PID with the exact cwd${requiredPid === undefined ? "" : " and retained PID"}.`); return { pid: canonical!.pid as number }; }
|
|
149
|
+
async #exactProcessAfterReconnect(requiredPid: number): Promise<{ pid: number }> { try { return await this.#exactProcess(requiredPid); } catch (error) { if (!(error instanceof HerdrTransportError) || this.owner.snapshot.connection !== "unknown") throw error; await this.reconnect(); return this.#exactProcess(requiredPid); } }
|
|
150
|
+
async #waitForStartup(clock: HerdrCodexMonitorClock = { now: () => Date.now(), wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) }): Promise<{ pid: number; text: string }> { const started = clock.now(), deadline = started + EXTERNAL_STARTUP_TIMEOUT_MS; let last = "not ready"; for (;;) { const text = await this.#readCurrentPane(); let uiReady: boolean; if (this.launch.kind === "cursor") uiReady = this.#cursorReady(text); else { if (codexAttention(text)) throw this.#needsAttention("Placed Codex requires attention before input; truthful pane retained for inspection."); uiReady = codexReady(text); } const projection = await this.#processProjection(); const current = parseHerdrAgent(await this.owner.connection.client.call<Record<string, unknown>>("agent.get", { target: this.owner.agentName }), "agent_info"); if (!ownsHerdrPane(current, this.owner.terminalId!, this.owner.owned!.paneId) || current.name !== this.owner.agentName) throw new Error("External startup agent owner, pane, terminal, or name identity changed."); const status = current.agent_status; if (status === "blocked" || status === "action_required") throw this.#needsAttention(`Placed ${this.launch.kind === "cursor" ? "Cursor" : "Codex"} requires attention before input; truthful pane retained for inspection.`); if (current.interactive_ready === true && current.agent !== this.launch.kind) throw new Error("External startup agent kind identity changed."); const managedReady = current.agent === this.launch.kind && current.interactive_ready === true && current.launch_pending !== true && (status === "idle" || status === "done"); if (uiReady && projection.canonical.length === 1 && managedReady) return { pid: projection.canonical[0]!.pid as number, text }; last = `${uiReady ? "ready UI" : "startup UI"}; ${projection.canonical.length} canonical processes; managed ${managedReady ? "ready" : String(status ?? "unknown")}`; const now = clock.now(); if (now >= deadline) break; const poll = now - started < EXTERNAL_STARTUP_BACKOFF_AFTER_MS ? CODEX_POLL_MS : EXTERNAL_STARTUP_SLOW_POLL_MS; await clock.wait(Math.min(poll, deadline - now)); } throw this.#needsAttention(`Placed ${this.launch.kind === "cursor" ? "Cursor" : "Codex"} startup did not stabilize (${last}); truthful pane retained for inspection.`); }
|
|
151
|
+
async #codexSnapshotOnce(pid: number): Promise<HerdrCodexSnapshot> { const current = parseHerdrAgent(await this.owner.connection.client.call<Record<string, unknown>>("agent.get", { target: this.owner.agentName }), "agent_info"); if (!ownsHerdrPane(current, this.owner.terminalId!, this.owner.owned!.paneId)) throw new Error("Codex owner identity changed."); const status = current.agent_status, source = status === "idle" || status === "done" ? "recent_unwrapped" : "visible", process = await this.#exactProcess(pid), read = unwrap<Record<string, unknown>>(await this.owner.connection.client.call("pane.read", { pane_id: this.owner.owned!.paneId, source, lines: 400, strip_ansi: true }), "pane_read", "read"); if (read.pane_id !== this.owner.owned!.paneId || read.source !== source) throw new Error("Codex pane read identity or source changed."); return { at: Date.now(), workspaceId: this.owner.owned!.workspaceId, paneId: String(read.pane_id), terminalId: this.owner.terminalId!, pid: process.pid, text: String(read.text ?? ""), source }; }
|
|
152
|
+
async #codexSnapshot(pid: number): Promise<HerdrCodexSnapshot> { try { return await this.#codexSnapshotOnce(pid); } catch (error) { if (!(error instanceof HerdrTransportError) || this.owner.snapshot.connection !== "unknown") throw error; await this.reconnect(); return this.#codexSnapshotOnce(pid); } }
|
|
153
|
+
#remoteEvidence(script: string, args: string[]): string { const result = this.owner.runRemote(remoteShellCommand(`exec node -e ${shellQuoteRemote(script)} "$@"`, args), { timeout: 10_000, maxBuffer: MAX_EVIDENCE_BYTES }); if (result.status !== 0 || result.stderr) throw new Error(`Could not read exact owned external evidence: ${String(result.stderr).slice(0, 512)}`); return String(result.stdout); }
|
|
154
|
+
handleDisconnect(observed: HerdrForwardedConnection = this.owner.connection): void { const owner = this.owner as HerdrPlacedRunOwner & { markConnectionUnknown?: (connection: HerdrForwardedConnection) => boolean }; if (owner.markConnectionUnknown ? !owner.markConnectionUnknown(observed) : this.owner.connection !== observed) return; void this.reconnect().catch(() => {}); }
|
|
155
|
+
reconnect(): Promise<void> { if (this.#reconnectFailure) return Promise.reject(this.#reconnectFailure); return this.#reconnectPromise ??= this.#performReconnect().catch((error) => { const failure = error instanceof Error ? error : new Error(String(error)); if (/reconnect remains unknown/u.test(failure.message)) this.#reconnectFailure = failure; throw failure; }).finally(() => { this.#reconnectPromise = undefined; }); }
|
|
156
|
+
async #performReconnect(): Promise<void> { if (this.#disposePromise) throw new Error("External reconnect is blocked because disposal or retention has begun."); if (!this.owner.identity) throw new Error("External reconnect has no persisted owner identity."); this.#reconnectDeadline ??= Date.now() + 15_000; let last: unknown; while (this.#reconnectCandidatesRemaining > 0 && Date.now() < this.#reconnectDeadline) { this.#reconnectCandidatesRemaining--; let connection: HerdrForwardedConnection | undefined, unsubscribe = () => {}, adopted = false, candidateLost: Error | undefined; try { if (this.#disposePromise) throw new Error("External reconnect is blocked because disposal or retention has begun."); connection = await this.#connect(this.owner.machine); const value = await connection.client.call<Record<string, unknown>>("session.snapshot"), envelope = unwrap<Record<string, unknown>>(value, "session_snapshot", "snapshot"), raw = Array.isArray(envelope.agents) ? envelope.agents as Record<string, unknown>[] : [], agents = raw.map((agent) => ({ terminal_id: typeof agent.terminal_id === "string" ? agent.terminal_id : undefined, pane_id: typeof agent.pane_id === "string" ? agent.pane_id : undefined, agent_status: typeof agent.agent_status === "string" ? agent.agent_status : undefined }));
|
|
157
|
+
const check = reconcileHerdrExternalRun(this.owner.identity, { endpoint: connection.endpoint, agents, bridge: {} }); if (!check.ok) throw new Error(check.reason);
|
|
158
|
+
if (this.#nativePid !== undefined) { validateStartedArgv(this.launch, this.owner.startedArgv); const canonicalArgv0 = path.posix.basename(canonicalPreflightBinary(this.launch.adapter, this.preflight)), process = unwrap<Record<string, unknown>>(await connection.client.call("pane.process_info", { pane_id: this.owner.identity.paneId }), "pane_process_info", "process_info"), rows = Array.isArray(process.foreground_processes) ? process.foreground_processes as Record<string, unknown>[] : [], canonical = rows.filter((row) => matchesExternalProcess(row, this.owner.identity!.cwd, canonicalArgv0)); if (process.pane_id !== this.owner.identity.paneId || canonical.length !== 1 || canonical[0]!.pid !== this.#nativePid) throw new Error("External canonical native PID, cwd, or argv0 changed during reconnect."); }
|
|
159
|
+
const observed = connection; unsubscribe = await connection.client.subscribe(herdrStatusSubscriptions(check.paneId), (event) => this.owner.observeEvent?.(event), (error) => { if (!adopted) { candidateLost ??= error; return; } this.handleDisconnect(observed); }); if (candidateLost) throw candidateLost; if (this.#disposePromise) throw new Error("External reconnect is blocked because disposal or retention has begun."); adopted = true; await this.owner.replaceConnection({ connection, unsubscribe, paneId: check.paneId, state: check.state }); return;
|
|
160
|
+
} catch (error) { last = error; unsubscribe(); await connection?.close(); }
|
|
161
|
+
} if (this.#disposePromise) throw new Error("External reconnect is blocked because disposal or retention has begun."); throw new Error(`Pane-native external reconnect remains unknown: ${last instanceof Error ? last.message : String(last)}`); }
|
|
162
|
+
retain(): Promise<void> { return this.#disposePromise ??= this.owner.cleanup(false); }
|
|
163
|
+
abort(): Promise<void> { return this.#dispose(); } dispose(): Promise<void> { return this.#dispose(); } #dispose(): Promise<void> { return this.#disposePromise ??= this.owner.cleanup(true); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function prepareInternalHerdrExternalAdapter(input: { adapter: HerdrExternalAdapterId; machine: HerdrMachineReference; runId: string }, launch: Omit<Parameters<typeof createHerdrExternalAdapterLaunch>[0], "adapter" | "remoteRuntimeDir" | "cwd">, createOwner: typeof HerdrPlacedRunOwner.create = HerdrPlacedRunOwner.create): Promise<HerdrExternalSession> {
|
|
167
|
+
let evidence: HerdrExternalPreflightEvidence | undefined; const owner = await createOwner(input.machine, input.runId, (owned) => { evidence = runHerdrExternalPreflight(owned, input.adapter); });
|
|
168
|
+
try { const descriptor = createHerdrExternalAdapterLaunch({ adapter: input.adapter, remoteRuntimeDir: owner.runtimeDir, ...launch, cwd: input.machine.cwd }); let session: HerdrExternalSession | undefined; const owned = await owner.provision({}); await owner.subscribe(() => {}, () => { void session?.reconnect().catch(() => {}); }); const agentName = `${descriptor.kind}-${input.runId.slice(-20)}`, started = await owner.start(descriptor.kind, agentName, descriptor.args); validateStartedArgv(descriptor, started.argv); owner.journal({ runId: input.runId, machineId: input.machine.id, target: input.machine.target, session: owner.connection.endpoint.session, workspaceId: owned.workspaceId, tabId: owned.tabId, paneId: owned.paneId, terminalId: started.terminalId, agentName, nativeSessionId: descriptor.nativeSessionId, cwd: input.machine.cwd, runtimeDir: owner.runtimeDir }); return session = new HerdrExternalSession(owner, descriptor, evidence!); } catch (error) { try { await owner.cleanup(true); } catch (cleanupError) { throw new AggregateError([error, cleanupError], "External adapter preparation and cleanup both failed.", { cause: error }); } throw error; }
|
|
169
|
+
}
|