pi-subagents 0.67.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/docs/agents.md +41 -12
  4. package/docs/configuration.md +61 -19
  5. package/docs/extension-api.md +5 -1
  6. package/docs/missions.md +2 -2
  7. package/docs/models.md +11 -79
  8. package/docs/observability.md +18 -8
  9. package/docs/standalone-background.md +13 -3
  10. package/docs/tool-reference.md +38 -14
  11. package/docs/watchdog.md +10 -12
  12. package/docs/workflows.md +59 -1
  13. package/index.ts +5 -2
  14. package/package.json +4 -2
  15. package/runner-peer-loader.mjs +24 -0
  16. package/runner-peer-preload.mjs +25 -11
  17. package/skills/pi-subagents/SKILL.md +18 -21
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  19. package/skills/pi-subagents/references/execution-controls.md +6 -4
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
  21. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  22. package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
  23. package/skills/pi-subagents/references/review-and-validation.md +3 -3
  24. package/src/agents/agent-management.ts +57 -58
  25. package/src/agents/agent-serializer.ts +4 -3
  26. package/src/agents/agents.ts +185 -72
  27. package/src/agents/chain-serializer.ts +5 -0
  28. package/src/agents/runtime-agent-registry.ts +7 -6
  29. package/src/agents/skills.ts +1 -1
  30. package/src/api/preflight.ts +20 -16
  31. package/src/api/required-child-extensions.ts +6 -0
  32. package/src/extension/config.ts +10 -37
  33. package/src/extension/fanout-child.ts +3 -0
  34. package/src/extension/herdr-pi-bridge.ts +160 -0
  35. package/src/extension/index.ts +42 -31
  36. package/src/extension/public-execution.ts +3 -3
  37. package/src/extension/schemas.ts +23 -6
  38. package/src/extension/tool-description.ts +8 -7
  39. package/src/inspectors/ghostty/plugin.ts +13 -1
  40. package/src/intercom/native-supervisor-channel.ts +22 -18
  41. package/src/policy/authority.ts +4 -0
  42. package/src/profiles/profiles.ts +12 -6
  43. package/src/runs/background/active-run-index.ts +17 -1
  44. package/src/runs/background/async-execution.ts +309 -126
  45. package/src/runs/background/async-job-tracker.ts +8 -6
  46. package/src/runs/background/async-resume.ts +13 -4
  47. package/src/runs/background/async-status.ts +15 -4
  48. package/src/runs/background/auto-drain.ts +20 -10
  49. package/src/runs/background/binary-bootstrap.ts +5 -0
  50. package/src/runs/background/chain-append.ts +1 -1
  51. package/src/runs/background/chain-root-attachment.ts +14 -33
  52. package/src/runs/background/notify.ts +74 -6
  53. package/src/runs/background/result-files.ts +8 -4
  54. package/src/runs/background/result-watcher.ts +19 -2
  55. package/src/runs/background/run-child-session.ts +20 -29
  56. package/src/runs/background/runner-aliases.ts +4 -33
  57. package/src/runs/background/runner-child-launch.ts +4 -1
  58. package/src/runs/background/runner-child-sessions.ts +2 -2
  59. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  60. package/src/runs/background/scheduled-runs.ts +11 -5
  61. package/src/runs/background/stale-run-reconciler.ts +35 -11
  62. package/src/runs/background/subagent-runner.ts +413 -276
  63. package/src/runs/background/subagent-wait.ts +128 -23
  64. package/src/runs/background/wait-completions.ts +75 -27
  65. package/src/runs/background/wait-subscriptions.ts +9 -3
  66. package/src/runs/background/wait-tool.ts +4 -2
  67. package/src/runs/foreground/async-stop-action.ts +93 -3
  68. package/src/runs/foreground/execution.ts +115 -219
  69. package/src/runs/foreground/foreground-history.ts +2 -1
  70. package/src/runs/foreground/subagent-executor.ts +281 -80
  71. package/src/runs/shared/acceptance.ts +194 -37
  72. package/src/runs/shared/async-status-projection.ts +123 -33
  73. package/src/runs/shared/child-launch-plan.ts +15 -3
  74. package/src/runs/shared/child-launch.ts +19 -6
  75. package/src/runs/shared/child-runtime-config.ts +5 -0
  76. package/src/runs/shared/child-session.ts +94 -50
  77. package/src/runs/shared/child-tool-plan.ts +28 -16
  78. package/src/runs/shared/dynamic-fanout.ts +2 -2
  79. package/src/runs/shared/external-cli-contract.ts +11 -1
  80. package/src/runs/shared/external-cli-preflight.ts +6 -2
  81. package/src/runs/shared/herdr-connection.ts +134 -0
  82. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  83. package/src/runs/shared/herdr-machine.ts +279 -0
  84. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  85. package/src/runs/shared/herdr-placed-run.ts +263 -0
  86. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  87. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
  88. package/src/runs/shared/model-scope.ts +1 -1
  89. package/src/runs/shared/nested-events.ts +11 -2
  90. package/src/runs/shared/parallel-utils.ts +7 -2
  91. package/src/runs/shared/pi-spawn.ts +1 -1
  92. package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  93. package/src/runs/shared/worktree-setup-command.ts +27 -4
  94. package/src/runs/shared/worktree.ts +30 -8
  95. package/src/shared/child-cache-retention.ts +43 -0
  96. package/src/shared/launch-contract.ts +6 -9
  97. package/src/shared/pruned-fork.ts +1 -1
  98. package/src/shared/required-child-extensions.ts +81 -0
  99. package/src/shared/settings.ts +5 -2
  100. package/src/shared/shortcuts.ts +0 -4
  101. package/src/shared/types.ts +81 -29
  102. package/src/slash/slash-commands.ts +0 -6
  103. package/src/slash/subagents-admin.ts +13 -9
  104. package/src/tui/render.ts +20 -10
  105. package/src/watchdog/child-status.ts +28 -36
  106. package/src/watchdog/lsp-diagnostics.ts +1 -1
  107. package/src/watchdog/model-selection.ts +1 -1
  108. package/src/watchdog/register-child.ts +10 -3
  109. package/src/watchdog/register-main.ts +20 -20
  110. package/src/watchdog/render.ts +1 -1
  111. package/src/watchdog/review.ts +14 -30
  112. package/src/watchdog/rules.ts +1 -1
  113. package/src/watchdog/runtime.ts +23 -12
  114. package/src/watchdog/settings.ts +3 -6
  115. package/src/watchdog/types.ts +3 -5
  116. package/src/watchdog/warning-format.ts +1 -1
  117. package/src/workflows/scripted-workflow.ts +68 -7
  118. package/src/workflows/workflow-receipt.ts +21 -3
  119. package/src/workflows/workflow-resources.ts +13 -2
  120. package/src/runs/shared/model-exclusions.ts +0 -374
  121. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  122. package/src/runs/shared/readonly-session-evidence.ts +0 -307
@@ -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
+ }
@@ -0,0 +1,279 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { ExternalProcessStatus, HerdrMachineReference } from "../../shared/types.ts";
5
+ import { getAgentDir, getProjectConfigDir } from "../../shared/utils.ts";
6
+ import { CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS, type CodeOwnedExternalCliAdapterId } from "./external-cli-contract.ts";
7
+ import type { runExternalCli } from "./external-cli-runner.ts";
8
+ export { shellQuoteRemote as shellQuote } from "./herdr-connection.ts";
9
+
10
+ /**
11
+ * Herdr saved-machine placement for external CLI children.
12
+ *
13
+ * Herdr owns which machines exist and how ssh reaches them (`herdr machine list --json`).
14
+ * pi-subagents owns what runs there: native Pi and the six code-owned external profiles are
15
+ * launched in fresh Herdr-owned visible panes. SSH is bounded transport and never owns the agent.
16
+ * `cwd` means the directory on that machine; the remote `cd` is the directory check.
17
+ */
18
+
19
+ const MAX_MACHINE_NAME_LENGTH = 128;
20
+ const HERDR_MACHINE_LIST_TIMEOUT_MS = 7_500;
21
+ const MAX_HERDR_MACHINE_LIST_BYTES = 1024 * 1024;
22
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
23
+ const SUPPORTED_MACHINE_ADAPTERS = new Set<string>(CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS);
24
+ /** The local ssh process gets only what ssh itself needs; remote runs use the machine's own credentials. */
25
+ export const HERDR_SSH_ENV_ALLOWLIST = ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "SSH_AUTH_SOCK"] as const;
26
+
27
+ type RunExternalCliInput = Parameters<typeof runExternalCli>[0];
28
+
29
+ interface HerdrMachineCatalogEntry {
30
+ id: string;
31
+ label?: string;
32
+ target: string;
33
+ session?: string;
34
+ enabled: boolean;
35
+ }
36
+
37
+ interface MachineSettingsEntry {
38
+ cwd?: string;
39
+ env?: Record<string, string>;
40
+ }
41
+
42
+ export interface ResolveHerdrMachinePlacementInput {
43
+ /** Profile id or label as typed by the operator. */
44
+ machine: string;
45
+ /** Local directory whose project settings hold `subagents.machines`. */
46
+ cwd: string;
47
+ /** Launch cwd: absolute or `~` paths are remote paths as given; relative paths join the configured machine root. */
48
+ stepCwd?: string;
49
+ env?: NodeJS.ProcessEnv;
50
+ /** Test seam: catalog JSON instead of spawning `herdr machine list --json`. */
51
+ catalogJson?: string;
52
+ /** Test seam: settings entry instead of reading settings files. */
53
+ settings?: MachineSettingsEntry;
54
+ herdrBin?: string;
55
+ }
56
+
57
+ export interface HerdrMachinePlacement {
58
+ machine: HerdrMachineReference;
59
+ /** Opt-in `subagents.machines.<name>.env`, exported in front of the remote command. */
60
+ env?: Record<string, string>;
61
+ }
62
+
63
+ export interface PreparedHerdrMachineExternalCliRun {
64
+ input: RunExternalCliInput;
65
+ decorateProcess(process: ExternalProcessStatus): ExternalProcessStatus;
66
+ }
67
+
68
+ function validateMachineName(value: string): string {
69
+ const trimmed = value.trim();
70
+ if (!trimmed) throw new Error("Herdr machine id or label is required.");
71
+ if (trimmed.length > MAX_MACHINE_NAME_LENGTH) throw new Error(`Herdr machine '${trimmed.slice(0, 24)}…' exceeds ${MAX_MACHINE_NAME_LENGTH} characters.`);
72
+ if (CONTROL_CHARS.test(trimmed)) throw new Error("Herdr machine id or label contains control characters.");
73
+ return trimmed;
74
+ }
75
+
76
+ function validateTarget(value: string, requested: string): string {
77
+ const target = value.trim();
78
+ if (!target || target.startsWith("-") || /[\s\u0000-\u001f\u007f]/u.test(target)) {
79
+ throw new Error(`Herdr machine '${requested}' has an ssh target that cannot be passed safely: ${JSON.stringify(target)}.`);
80
+ }
81
+ return target;
82
+ }
83
+
84
+ function isRemoteAbsolute(value: string): boolean {
85
+ return value.startsWith("/") || value === "~" || value.startsWith("~/");
86
+ }
87
+
88
+ function validateRemoteCwd(value: string, requested: string): string {
89
+ const cwd = value.trim();
90
+ if (!isRemoteAbsolute(cwd)) throw new Error(`Herdr machine '${requested}' cwd must be an absolute POSIX path or start with '~': ${JSON.stringify(cwd)}.`);
91
+ if (CONTROL_CHARS.test(cwd)) throw new Error(`Herdr machine '${requested}' cwd contains control characters.`);
92
+ return cwd.length > 1 ? cwd.replace(/\/+$/u, "") : cwd;
93
+ }
94
+
95
+ function parseMachineCatalog(json: string): HerdrMachineCatalogEntry[] {
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(json) as unknown;
99
+ } catch (error) {
100
+ throw new Error(`Failed to parse herdr machine list --json: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+ const entries = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === "object" && Array.isArray((parsed as Record<string, unknown>).machines) ? (parsed as Record<string, unknown[]>).machines : undefined);
103
+ if (!entries) throw new Error("herdr machine list --json returned no machine list.");
104
+ return entries.flatMap((entry) => {
105
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
106
+ const record = entry as Record<string, unknown>;
107
+ if (typeof record.id !== "string" || !record.id.trim() || typeof record.target !== "string") return [];
108
+ return [{
109
+ id: record.id.trim(),
110
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
111
+ target: record.target,
112
+ ...(typeof record.session === "string" && record.session.trim() ? { session: record.session.trim() } : {}),
113
+ enabled: record.enabled !== false,
114
+ }];
115
+ });
116
+ }
117
+
118
+ function readHerdrMachineCatalog(env: NodeJS.ProcessEnv, herdrBin: string): HerdrMachineCatalogEntry[] {
119
+ const result = spawnSync(herdrBin, ["machine", "list", "--json"], {
120
+ env,
121
+ encoding: "utf-8",
122
+ maxBuffer: MAX_HERDR_MACHINE_LIST_BYTES,
123
+ timeout: HERDR_MACHINE_LIST_TIMEOUT_MS,
124
+ windowsHide: true,
125
+ });
126
+ if (result.error) {
127
+ const code = (result.error as NodeJS.ErrnoException).code;
128
+ if (code === "ENOENT") throw new Error(`Herdr CLI '${herdrBin}' was not found on PATH. Saved-machine placement needs Herdr installed locally.`);
129
+ throw new Error(`Failed to run herdr machine list --json: ${result.error.message}`);
130
+ }
131
+ if (result.status !== 0) throw new Error(`herdr machine list --json exited with code ${result.status}: ${(result.stderr || result.stdout).trim()}`);
132
+ return parseMachineCatalog(result.stdout);
133
+ }
134
+
135
+ /** Mirrors Herdr's own selector rules: profile id first, then a unique case-sensitive label; disabled fails closed. */
136
+ function selectMachine(catalog: HerdrMachineCatalogEntry[], requested: string): HerdrMachineCatalogEntry {
137
+ const byId = catalog.find((entry) => entry.id === requested);
138
+ const matches = byId ? [byId] : catalog.filter((entry) => entry.label === requested);
139
+ if (matches.length > 1) throw new Error(`Machine label '${requested}' is ambiguous; use its profile ID.`);
140
+ const machine = matches[0];
141
+ if (!machine) {
142
+ const saved = catalog.filter((entry) => entry.enabled).map((entry) => entry.label ?? entry.id);
143
+ throw new Error(`Herdr machine '${requested}' was not found. Saved machines: ${saved.length ? saved.join(", ") : "none"}. Add one with herdr machine add <target> --label <name>.`);
144
+ }
145
+ if (!machine.enabled) throw new Error(`Machine '${requested}' is disabled. Run herdr machine enable ${machine.id}.`);
146
+ return machine;
147
+ }
148
+
149
+ function readJsonObject(filePath: string): Record<string, unknown> {
150
+ if (!fs.existsSync(filePath)) return {};
151
+ let parsed: unknown;
152
+ try {
153
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
154
+ } catch (error) {
155
+ throw new Error(`Failed to read settings file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
156
+ }
157
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
158
+ }
159
+
160
+ function machineSettingsFrom(settings: Record<string, unknown>, keys: readonly string[], filePath: string): MachineSettingsEntry | undefined {
161
+ const subagents = settings.subagents;
162
+ if (!subagents || typeof subagents !== "object" || Array.isArray(subagents)) return undefined;
163
+ const machines = (subagents as Record<string, unknown>).machines;
164
+ if (machines === undefined) return undefined;
165
+ if (!machines || typeof machines !== "object" || Array.isArray(machines)) throw new Error(`Subagent settings in '${filePath}' have invalid 'machines'; expected an object keyed by machine label or id.`);
166
+ const key = keys.find((candidate) => candidate in (machines as Record<string, unknown>));
167
+ if (key === undefined) return undefined;
168
+ const value = (machines as Record<string, unknown>)[key];
169
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Subagent settings in '${filePath}' have invalid 'machines.${key}'; expected an object with 'cwd' and optional 'env'.`);
170
+ const record = value as Record<string, unknown>;
171
+ const entry: MachineSettingsEntry = {};
172
+ if (record.cwd !== undefined) {
173
+ if (typeof record.cwd !== "string" || !record.cwd.trim()) throw new Error(`Subagent settings in '${filePath}' have invalid 'machines.${key}.cwd'; expected a non-empty string.`);
174
+ entry.cwd = validateRemoteCwd(record.cwd, key);
175
+ }
176
+ if (record.env !== undefined) {
177
+ if (!record.env || typeof record.env !== "object" || Array.isArray(record.env) || Object.entries(record.env).some(([name, item]) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) || typeof item !== "string")) {
178
+ throw new Error(`Subagent settings in '${filePath}' have invalid 'machines.${key}.env'; expected an object of string values keyed by variable name.`);
179
+ }
180
+ entry.env = { ...(record.env as Record<string, string>) };
181
+ if (Object.keys(entry.env).length > 0) {
182
+ throw new Error(`Subagent settings in '${filePath}' set 'machines.${key}.env'. Saved-machine runs use the remote Herdr/Pi environment; configure credentials and environment on '${key}' and remove the local env map.`);
183
+ }
184
+ }
185
+ return entry;
186
+ }
187
+
188
+ /** Project settings beat user settings, field by field; the entry may be keyed by label, id, or the typed selector. */
189
+ function readMachineSettings(cwd: string, keys: readonly string[]): MachineSettingsEntry | undefined {
190
+ const userPath = path.join(getAgentDir(), "settings.json");
191
+ const projectPath = path.join(getProjectConfigDir(findProjectRootForSettings(cwd)), "settings.json");
192
+ const user = machineSettingsFrom(readJsonObject(userPath), keys, userPath);
193
+ const project = projectPath === userPath ? undefined : machineSettingsFrom(readJsonObject(projectPath), keys, projectPath);
194
+ if (!user && !project) return undefined;
195
+ return { ...user, ...project };
196
+ }
197
+
198
+ function findProjectRootForSettings(cwd: string): string {
199
+ let current = path.resolve(cwd);
200
+ while (true) {
201
+ if (fs.existsSync(path.join(getProjectConfigDir(current), "settings.json"))) return current;
202
+ const parent = path.dirname(current);
203
+ if (parent === current) return path.resolve(cwd);
204
+ current = parent;
205
+ }
206
+ }
207
+
208
+ export function resolveHerdrMachinePlacement(input: ResolveHerdrMachinePlacementInput): HerdrMachinePlacement {
209
+ const requested = validateMachineName(input.machine);
210
+ const catalog = input.catalogJson !== undefined
211
+ ? parseMachineCatalog(input.catalogJson)
212
+ : readHerdrMachineCatalog(input.env ?? process.env, input.herdrBin ?? process.env.HERDR_BIN ?? "herdr");
213
+ const selected = selectMachine(catalog, requested);
214
+ const name = selected.label ?? selected.id;
215
+ const keys = [...new Set([requested, ...(selected.label ? [selected.label] : []), selected.id])];
216
+ const settings = input.settings ?? readMachineSettings(input.cwd, keys);
217
+ if (settings?.env && Object.keys(settings.env).length > 0) {
218
+ throw new Error(`Saved-machine environment for '${name}' must be configured remotely. Remove machines.${name}.env and configure the remote Herdr/Pi session instead.`);
219
+ }
220
+ const stepCwd = input.stepCwd?.trim();
221
+ let cwd: string;
222
+ if (stepCwd && isRemoteAbsolute(stepCwd)) cwd = stepCwd;
223
+ else if (settings?.cwd) cwd = stepCwd ? path.posix.join(settings.cwd, stepCwd) : settings.cwd;
224
+ else throw new Error(`No root for ${name} in this repo. Set subagents.machines.${name}.cwd in .pi/settings.json or pass an absolute cwd on that machine.`);
225
+ return {
226
+ machine: {
227
+ provider: "herdr",
228
+ id: selected.id,
229
+ ...(selected.label ? { label: selected.label } : {}),
230
+ target: validateTarget(selected.target, requested),
231
+ ...(selected.session && selected.session !== "default" ? { session: selected.session } : {}),
232
+ cwd: validateRemoteCwd(cwd, requested),
233
+ },
234
+ ...(settings?.env ? { env: settings.env } : {}),
235
+ };
236
+ }
237
+
238
+ export function formatHerdrMachineRunnerUnsupported(input: {
239
+ machine?: string;
240
+ agentName: string;
241
+ runnerType?: string;
242
+ adapter?: string;
243
+ worktree?: boolean;
244
+ }): string | undefined {
245
+ if (input.machine === undefined) return undefined;
246
+ if (input.runnerType !== undefined && input.runnerType !== "pi" && input.runnerType !== "external-cli") {
247
+ return `Agent '${input.agentName}' requested machine '${input.machine}', but this runner cannot use pane-native Herdr placement. Use native Pi or a built-in Claude, Codex, or Cursor profile.`;
248
+ }
249
+ if (input.runnerType === "external-cli" && (input.adapter === undefined || !SUPPORTED_MACHINE_ADAPTERS.has(input.adapter))) {
250
+ return `Agent '${input.agentName}' requested machine '${input.machine}', but generic external-cli commands cannot be remote-wrapped safely. Use claude-code, claude-code-writer, codex-exec, codex-exec-writer, cursor-agent, or cursor-agent-writer.`;
251
+ }
252
+ if (input.worktree === true) return `Agent '${input.agentName}' requested machine '${input.machine}', but managed worktrees are local git operations and cannot be combined with a Herdr saved machine.`;
253
+ if (process.platform === "win32") return "Herdr saved-machine pane transport requires hardened OpenSSH StreamLocal forwarding, which is not supported from a Windows host yet.";
254
+ return undefined;
255
+ }
256
+
257
+ /** Legacy local-child SSH wrapping is intentionally unavailable after the pane-native cut-over. */
258
+ export function prepareHerdrMachineExternalCliRun(input: RunExternalCliInput, placement: HerdrMachinePlacement | undefined, _options: { localCwd: string }): PreparedHerdrMachineExternalCliRun {
259
+ if (placement) throw new Error("Saved-machine external profiles must run in a Herdr-owned pane.");
260
+ return { input, decorateProcess: (process) => process };
261
+ }
262
+ const HINTS: ReadonlyArray<readonly [RegExp, (machine: HerdrMachineReference) => string]> = [
263
+ [/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|Permission denied \(publickey|Permission denied, please try again|No such identity|Could not resolve hostname|Connection (timed out|refused)/iu,
264
+ (machine) => `ssh could not reach or authenticate with ${machine.target}. Connect once interactively with ssh ${machine.target} to accept the host key or fix the identity; BatchMode never prompts.`],
265
+ [/is not recognized as|CommandNotFoundException|PowerShell|At line:\d+ char:\d+/u,
266
+ (machine) => `Machine '${machine.label ?? machine.id}' is not a POSIX host. External-cli runs support POSIX ssh targets only.`],
267
+ [/\bcd: .*(No such file or directory|not a directory|can't cd)|exit(?:ed with)? code 125\b/iu,
268
+ (machine) => `Nothing at ${machine.cwd} on ${machine.label ?? machine.id}. Clone the repo there first; pi-subagents never clones, pulls, or checks out on a machine.`],
269
+ [/(?:command not found|not found)\s*$|No such file or directory\s*$|exit(?:ed with)? code 127\b/imu,
270
+ (machine) => `The agent CLI was not found on ${machine.label ?? machine.id}. Non-interactive shells skip rc files and the PATH prefix did not find it; set the agent's command to the absolute path on that machine.`],
271
+ [/not logged in|unauthorized|authentication_error|invalid api key|please run .*login|OAuth token/iu,
272
+ (machine) => `Remote runs use the machine's own credentials. Log in to the agent CLI on ${machine.label ?? machine.id} once.`],
273
+ ];
274
+
275
+ /** One-line operator hint for a predictable remote failure, matched against error text and the stderr tail. */
276
+ export function formatHerdrMachineHint(machine: HerdrMachineReference, text: string): string | undefined {
277
+ for (const [pattern, hint] of HINTS) if (pattern.test(text)) return hint(machine);
278
+ return undefined;
279
+ }
@@ -0,0 +1,59 @@
1
+ import * as path from "node:path";
2
+
3
+ export const HERDR_PI_PROTOCOL = 1;
4
+ export const HERDR_PI_MAX_FRAME_BYTES = 1024 * 1024;
5
+ export const HERDR_PI_MODE_ENV = "PI_SUBAGENTS_HERDR_BRIDGE";
6
+ export const HERDR_PI_RUN_ENV = "PI_SUBAGENTS_HERDR_RUN_ID";
7
+ export const HERDR_PI_RUNTIME_DIR_ENV = "PI_SUBAGENTS_HERDR_RUNTIME_DIR";
8
+ const SAFE_ID = /^[A-Za-z0-9_-]{8,96}$/u;
9
+
10
+ export function validateHerdrPiRunId(value: unknown): string {
11
+ if (typeof value !== "string" || !SAFE_ID.test(value)) throw new Error("Herdr Pi bridge run identity is invalid.");
12
+ return value;
13
+ }
14
+
15
+ export function herdrPiRuntimeRoot(agentDir: string): string {
16
+ return path.join(agentDir, "pi-subagents", "herdr-runs");
17
+ }
18
+
19
+ export function herdrPiRunDir(agentDir: string, runId: string): string {
20
+ return path.join(herdrPiRuntimeRoot(agentDir), validateHerdrPiRunId(runId));
21
+ }
22
+
23
+ export interface HerdrPiFrame {
24
+ protocol: number;
25
+ runId: string;
26
+ type: string;
27
+ requestId?: string;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export function encodeHerdrPiFrame(frame: HerdrPiFrame): Buffer {
32
+ const data = Buffer.from(`${JSON.stringify(frame)}\n`, "utf8");
33
+ if (data.byteLength > HERDR_PI_MAX_FRAME_BYTES) throw new Error(`Herdr Pi frame exceeds ${HERDR_PI_MAX_FRAME_BYTES} bytes.`);
34
+ return data;
35
+ }
36
+
37
+ export class HerdrPiFrameDecoder {
38
+ #buffer = Buffer.alloc(0);
39
+ push(chunk: Buffer): HerdrPiFrame[] {
40
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
41
+ if (this.#buffer.byteLength > HERDR_PI_MAX_FRAME_BYTES) throw new Error(`Herdr Pi frame exceeds ${HERDR_PI_MAX_FRAME_BYTES} bytes.`);
42
+ const frames: HerdrPiFrame[] = [];
43
+ for (;;) {
44
+ const newline = this.#buffer.indexOf(10);
45
+ if (newline < 0) break;
46
+ const line = this.#buffer.subarray(0, newline);
47
+ this.#buffer = this.#buffer.subarray(newline + 1);
48
+ let value: unknown;
49
+ try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)) as unknown; } catch { throw new Error("Herdr Pi bridge emitted malformed JSON or UTF-8."); }
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Herdr Pi bridge frame must be an object.");
51
+ const frame = value as HerdrPiFrame;
52
+ if (frame.protocol !== HERDR_PI_PROTOCOL || typeof frame.type !== "string") throw new Error("Herdr Pi bridge protocol mismatch.");
53
+ validateHerdrPiRunId(frame.runId);
54
+ frames.push(frame);
55
+ }
56
+ return frames;
57
+ }
58
+ end(): void { if (this.#buffer.byteLength) throw new Error("Herdr Pi bridge ended with a partial frame."); }
59
+ }