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
@@ -22,6 +22,7 @@ import {
22
22
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
23
23
  import { getAgentDir } from "../../shared/utils.ts";
24
24
  import type { PermissionRules } from "./permissions.ts";
25
+ import { snapshotRequiredChildExtensions, type RequiredChildExtensionSnapshot } from "../../shared/required-child-extensions.ts";
25
26
  import {
26
27
  capabilityCeilingAgentRestrictionSources,
27
28
  intersectSubagentCapabilityCeilings,
@@ -33,18 +34,18 @@ import {
33
34
  const MAX_LAUNCH_RESOLVED_EXTENSION_IDS = 32;
34
35
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(
35
36
  path.dirname(fileURLToPath(import.meta.url)),
36
- "subagent-prompt-runtime.ts",
37
+ `subagent-prompt-runtime${path.extname(fileURLToPath(import.meta.url))}`,
37
38
  );
38
39
  const FANOUT_CHILD_EXTENSION_PATH = path.join(
39
40
  path.dirname(fileURLToPath(import.meta.url)),
40
41
  "..",
41
42
  "..",
42
43
  "extension",
43
- "fanout-child.ts",
44
+ `fanout-child${path.extname(fileURLToPath(import.meta.url))}`,
44
45
  );
45
46
  const FAST_MODE_EXTENSION_PATH = path.join(
46
47
  path.dirname(fileURLToPath(import.meta.url)),
47
- "fast-mode-extension.ts",
48
+ `fast-mode-extension${path.extname(fileURLToPath(import.meta.url))}`,
48
49
  );
49
50
  const SUBAGENT_RUNTIME_EXTENSION_PATHS = new Set([
50
51
  PROMPT_RUNTIME_EXTENSION_PATH,
@@ -64,8 +65,6 @@ const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
64
65
  const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
65
66
  const REPOSITORY_INSPECTION_TOOLS = new Set(["read", "grep", "find", "ls", "bash", "powershell"]);
66
67
  const REVIEW_OR_SCOUT_AGENT_PATTERN = /\b(?:reviewer|scout)\b/i;
67
- // These providers come from child hooks, not the host's builtin tool registry.
68
- const NATIVE_COORDINATION_TOOL_NAMES = new Set(["subagent", "contact_supervisor", "subagent_supervisor"]);
69
68
 
70
69
  export function isReviewOrScoutLaneAgent(agentName: string | undefined): boolean {
71
70
  return typeof agentName === "string" && REVIEW_OR_SCOUT_AGENT_PATTERN.test(agentName);
@@ -149,9 +148,9 @@ function stripThinkingSuffix(model: string): string {
149
148
  : model;
150
149
  }
151
150
 
152
- function resolveFastModeExtension(input: Pick<ResolvePiLaunchToolPlanInput, "fast" | "model" | "modelCandidates" | "agentName">): string[] {
151
+ function resolveFastModeExtension(input: Pick<ResolvePiLaunchToolPlanInput, "fast" | "model" | "agentName">): string[] {
153
152
  if (!input.fast) return [];
154
- const candidates = (input.modelCandidates?.length ? input.modelCandidates : input.model ? [input.model] : [])
153
+ const candidates = (input.model ? [input.model] : [])
155
154
  .map(stripThinkingSuffix);
156
155
  if (candidates.length === 0) {
157
156
  throw new Error(`fast mode requires an explicit supported native OpenAI-Codex model${input.agentName ? ` for agent '${input.agentName}'` : ""}.`);
@@ -169,6 +168,7 @@ export interface ResolvePiLaunchToolPlanInput {
169
168
  allowNestedSubagents?: boolean;
170
169
  extensions?: string[];
171
170
  subagentOnlyExtensions?: string[];
171
+ requiredExtensions?: RequiredChildExtensionSnapshot;
172
172
  mcpDirectTools?: string[];
173
173
  cwd?: string;
174
174
  requireReadTool?: boolean;
@@ -181,19 +181,19 @@ export interface ResolvePiLaunchToolPlanInput {
181
181
  };
182
182
  fast?: boolean;
183
183
  model?: string;
184
- modelCandidates?: readonly string[];
185
184
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
186
185
  inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
187
186
  agentName?: string;
188
187
  permissionRules?: PermissionRules;
189
188
  runtimeSnapshotHost?: McpRuntimeSnapshotHost;
190
189
  /**
191
- * When provided, child tool plans intersect declared builtin tools with
192
- * this set. Tools the agent declares but the host does not provide are
190
+ * When provided, child tool plans intersect known Pi core tool slots with
191
+ * this set. Core tools the agent declares but the host does not provide are
193
192
  * omitted with a non-fatal warning (tracked in `unavailableHostBuiltins`).
194
193
  * Review/scout lanes fail closed when a requested, still-permitted
195
194
  * repository inspection tool is among those host omissions. Intentionally
196
195
  * empty or ceiling-restricted allowlists are not a minimum-tool contract.
196
+ * Non-core names remain allowed and are validated in the child's registry.
197
197
  */
198
198
  hostAvailableBuiltins?: readonly string[];
199
199
  }
@@ -214,6 +214,7 @@ export interface PiLaunchToolPlan {
214
214
  fanoutAuthorized: boolean;
215
215
  runtimeExtensions: string[];
216
216
  configuredExtensions: string[];
217
+ requiredExtensions: RequiredChildExtensionSnapshot;
217
218
  extensionArgs: string[];
218
219
  disableAmbientExtensions: boolean;
219
220
  capabilityAudit?: SubagentCapabilityAudit;
@@ -258,6 +259,7 @@ export function projectLaunchResolvedChildExtensions(
258
259
  PiLaunchToolPlan,
259
260
  | "runtimeExtensions"
260
261
  | "configuredExtensions"
262
+ | "requiredExtensions"
261
263
  | "extensionArgs"
262
264
  | "disableAmbientExtensions"
263
265
  >,
@@ -271,10 +273,12 @@ export function projectLaunchResolvedChildExtensions(
271
273
  disableAmbientExtensions: toolPlan.disableAmbientExtensions,
272
274
  runtime: runtime.ids,
273
275
  configured: configured.ids,
276
+ required: toolPlan.requiredExtensions.map(({ id }) => id),
274
277
  effective: effective.ids,
275
278
  omitted: {
276
279
  runtime: runtime.omitted,
277
280
  configured: configured.omitted,
281
+ required: 0,
278
282
  effective: effective.omitted,
279
283
  },
280
284
  };
@@ -339,7 +343,8 @@ export function resolvePermissionSystemExtension(): string | undefined {
339
343
  /**
340
344
  * Extract the names of builtin tools the host provides. Use this to pass
341
345
  * `hostAvailableBuiltins` to `resolvePiLaunchToolPlan` so child tool plans
342
- * intersect declared agent tools with what the host actually supports.
346
+ * intersect known core slots with what the host actually supports. Wrapped
347
+ * core slots count regardless of source; host-specific builtins count too.
343
348
  *
344
349
  * Returns `undefined` when builtin tool discovery fails or yields nothing,
345
350
  * so callers skip the intersection (fail-safe to allowing all declared tools).
@@ -352,7 +357,7 @@ export function getHostBuiltinToolNames(pi: Pick<ExtensionAPI, "getAllTools">):
352
357
  .getAllTools()
353
358
  .filter((tool) => {
354
359
  const source = (tool.sourceInfo as { source?: string } | undefined)?.source;
355
- return source === "builtin" || (source === "auto" && PI_BUILTIN_TOOL_NAMES.has(tool.name));
360
+ return source === "builtin" || PI_BUILTIN_TOOL_NAMES.has(tool.name);
356
361
  })
357
362
  .map((tool) => tool.name);
358
363
  return builtins.length > 0 ? builtins : undefined;
@@ -368,6 +373,10 @@ export function resolvePiLaunchToolPlan(
368
373
  input.capabilityCeiling,
369
374
  input.inheritedCapabilityCeiling,
370
375
  );
376
+ const requiredExtensions = snapshotRequiredChildExtensions(input.requiredExtensions ?? []);
377
+ if (requiredExtensions.length > 0 && capabilityCeiling?.denyExtensions) {
378
+ throw new Error(`Capability ceiling from ${capabilityCeiling.sources.join(", ") || "unknown source"} denies extensions but this host requires: ${requiredExtensions.map(({ id }) => id).join(", ")}.`);
379
+ }
371
380
  const allowedToolSet =
372
381
  capabilityCeiling?.allowedTools === undefined
373
382
  ? undefined
@@ -405,10 +414,10 @@ export function resolvePiLaunchToolPlan(
405
414
  : requestedBuiltinTools
406
415
  ).filter((tool) => !allowedToolSet || allowedToolSet.has(tool));
407
416
  const declaredBuiltinTools = hostAvailableSet
408
- ? ceilingFilteredBuiltinTools.filter((tool) => hostAvailableSet.has(tool) || NATIVE_COORDINATION_TOOL_NAMES.has(tool))
417
+ ? ceilingFilteredBuiltinTools.filter((tool) => !PI_BUILTIN_TOOL_NAMES.has(tool) || hostAvailableSet.has(tool))
409
418
  : ceilingFilteredBuiltinTools;
410
419
  const unavailableHostBuiltins = hostAvailableSet
411
- ? ceilingFilteredBuiltinTools.filter((tool) => !hostAvailableSet.has(tool) && !NATIVE_COORDINATION_TOOL_NAMES.has(tool))
420
+ ? ceilingFilteredBuiltinTools.filter((tool) => PI_BUILTIN_TOOL_NAMES.has(tool) && !hostAvailableSet.has(tool))
412
421
  : [];
413
422
  const excludeTools = [...new Set((input.excludeTools ?? []).map((tool) => tool.trim()).filter(Boolean))];
414
423
  const excludedToolSet = new Set(excludeTools);
@@ -483,7 +492,7 @@ export function resolvePiLaunchToolPlan(
483
492
  ? resolvePermissionSystemExtension()
484
493
  : undefined;
485
494
  if (input.fast && capabilityCeiling?.denyExtensions) throw new Error("fast mode requires a child runtime extension, but this launch denies extensions.");
486
- const fastModeExtensions = resolveFastModeExtension({ fast: input.fast, model: input.model, modelCandidates: input.modelCandidates, agentName: input.agentName });
495
+ const fastModeExtensions = resolveFastModeExtension({ fast: input.fast, model: input.model, agentName: input.agentName });
487
496
  const runtimeExtensions = [
488
497
  PROMPT_RUNTIME_EXTENSION_PATH,
489
498
  ...fastModeExtensions,
@@ -510,7 +519,7 @@ export function resolvePiLaunchToolPlan(
510
519
  ...(input.extensions ?? []),
511
520
  ...(input.subagentOnlyExtensions ?? []),
512
521
  ];
513
- const extensionArgs = disableAmbientExtensions
522
+ const ordinaryExtensionArgs = disableAmbientExtensions
514
523
  ? [...new Set([...runtimeExtensions, ...configuredExtensions])]
515
524
  : [
516
525
  ...new Set([
@@ -519,6 +528,8 @@ export function resolvePiLaunchToolPlan(
519
528
  ...(input.subagentOnlyExtensions ?? []),
520
529
  ]),
521
530
  ];
531
+ // Host-required paths have final precedence and cannot be removed by agent defaults or overrides.
532
+ const extensionArgs = [...new Set([...ordinaryExtensionArgs, ...requiredExtensions.map(({ path }) => path)])];
522
533
  const requestedToolNames =
523
534
  input.tools !== undefined
524
535
  ? [
@@ -610,6 +621,7 @@ export function resolvePiLaunchToolPlan(
610
621
  fanoutAuthorized,
611
622
  runtimeExtensions,
612
623
  configuredExtensions,
624
+ requiredExtensions,
613
625
  extensionArgs,
614
626
  disableAmbientExtensions,
615
627
  warnings,
@@ -46,12 +46,12 @@ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency
46
46
  const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "sessionFiles", "thinkingOverrides"]);
47
47
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
48
48
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
49
- const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "fast", "toolBudget", "acceptance", "agentContract", "gateOn"]);
49
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "machine", "output", "outputMode", "reads", "progress", "skill", "model", "fast", "toolBudget", "acceptance", "agentContract", "gateOn"]);
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
52
  "outputName", "structured", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled", "waitToolDefaultTimeoutMs",
53
53
  "structuredOutput", "structuredOutputSchema", "tools", "excludeTools", "allowNestedSubagents", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
54
- "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions", "requestedCwd",
54
+ "systemPromptMode", "thinking", "requestedModel", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "requiredExtensions", "launchResolvedExtensions", "requestedCwd", "machineEnv",
55
55
  ]);
56
56
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
57
57
 
@@ -2,7 +2,9 @@ import type {
2
2
  ExternalCliReceiptMetadata,
3
3
  ExternalCliCapabilityNarrowing,
4
4
  ExternalCliRunnerStatus,
5
+ ExternalCliMachineStatus,
5
6
  ExternalProcessStatus,
7
+ HerdrMachineReference,
6
8
  } from "../../shared/types.ts";
7
9
 
8
10
  const UNSUPPORTED = {
@@ -80,6 +82,7 @@ export function resolveExternalCliRunnerStatus(input: {
80
82
  args?: string[];
81
83
  promptDelivery?: "stdin";
82
84
  capabilities?: ExternalCliCapabilityNarrowing;
85
+ machine?: HerdrMachineReference;
83
86
  }): ExternalCliRunnerStatus {
84
87
  const codexExec = input.adapter === "codex-exec";
85
88
  const codexExecWriter = input.adapter === "codex-exec-writer";
@@ -101,6 +104,7 @@ export function resolveExternalCliRunnerStatus(input: {
101
104
  ...(claudeCodeWriter ? { safety: { access: "workspace-write" as const, authentication: "existing-cli-required" as const, permissionMode: "acceptEdits" as const, tools: "Read,Write,Edit,Glob,Grep" as const, mcp: "empty-strict" as const, settingSources: "user" as const, userSettingsTrust: "required" as const, sessionPersistence: false as const } } : {}),
102
105
  ...(cursorAgent ? { safety: { access: "read-only" as const, authentication: "cursor-api-key-or-existing-login" as const, mode: "ask" as const, sandbox: "enabled" as const, workspaceTrust: "existing-required" as const, sessionReuse: false as const } } : {}),
103
106
  ...(cursorAgentWriter ? { safety: { access: "workspace-write" as const, authentication: "cursor-api-key-or-existing-login" as const, mode: "print" as const, sandbox: "enabled" as const, workspaceTrust: "existing-required" as const, sessionReuse: false as const } } : {}),
107
+ ...(input.machine ? { machine: input.machine } : {}),
104
108
  capabilities: {
105
109
  stop: true,
106
110
  steer: false,
@@ -124,6 +128,9 @@ export function normalizeExternalCliRunnerStatus(value: unknown): ExternalCliRun
124
128
  ? input.args
125
129
  : undefined;
126
130
  const promptDelivery = input.promptDelivery === "stdin" ? "stdin" : undefined;
131
+ const machine = input.machine && typeof input.machine === "object" && !Array.isArray(input.machine)
132
+ ? input.machine as HerdrMachineReference
133
+ : undefined;
127
134
  const adapterId = input.adapter && typeof input.adapter === "object" && !Array.isArray(input.adapter)
128
135
  ? (input.adapter as Record<string, unknown>).id
129
136
  : undefined;
@@ -131,6 +138,7 @@ export function normalizeExternalCliRunnerStatus(value: unknown): ExternalCliRun
131
138
  return {
132
139
  type: "external-cli",
133
140
  command: input.command,
141
+ ...(machine ? { machine } : {}),
134
142
  args: args ?? [],
135
143
  promptDelivery: "prompt-file",
136
144
  adapter: { id: "grok-build", version: 1, executionMode: "one-shot-prompt-file" },
@@ -140,7 +148,7 @@ export function normalizeExternalCliRunnerStatus(value: unknown): ExternalCliRun
140
148
  };
141
149
  }
142
150
  const adapter = isCodeOwnedExternalCliAdapterId(adapterId) ? adapterId : undefined;
143
- return resolveExternalCliRunnerStatus({ ...(adapter ? { adapter } : {}), command: input.command, ...(args ? { args } : {}), ...(promptDelivery ? { promptDelivery } : {}) });
151
+ return resolveExternalCliRunnerStatus({ ...(adapter ? { adapter } : {}), command: input.command, ...(args ? { args } : {}), ...(promptDelivery ? { promptDelivery } : {}), ...(machine ? { machine } : {}) });
144
152
  }
145
153
 
146
154
  export function externalCliReceiptMetadata(input: {
@@ -149,9 +157,11 @@ export function externalCliReceiptMetadata(input: {
149
157
  outputReference?: string;
150
158
  }): ExternalCliReceiptMetadata {
151
159
  const { runner } = input;
160
+ const machine: ExternalCliMachineStatus | undefined = input.externalProcess?.machine ?? runner.machine;
152
161
  return {
153
162
  adapter: { ...runner.adapter },
154
163
  capabilities: { ...runner.capabilities },
164
+ ...(machine ? { machine: { ...machine, ...(machine.remoteGit ? { remoteGit: { ...machine.remoteGit } } : {}) } } : {}),
155
165
  ...(runner.safety ? { safety: { ...runner.safety } } : {}),
156
166
  ...(input.externalProcess ? {
157
167
  outputArtifacts: {
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
 
5
5
  const MAX_PROBE_OUTPUT_BYTES = 256 * 1024;
6
6
  const MAX_PROBE_TIMEOUT_MS = 5_000;
7
+ const MAX_REMOTE_PROBE_TIMEOUT_MS = 15_000;
7
8
  const MAX_CACHE_ENTRIES = 64;
8
9
  const MAX_AVAILABILITY_REASON_LENGTH = 256;
9
10
 
@@ -16,6 +17,8 @@ export interface ExternalCliPreflightSpec {
16
17
  evidenceArgs?: readonly string[];
17
18
  evidenceLabel?: string;
18
19
  probeTimeoutMs?: number;
20
+ /** Remote probes include SSH handshakes; values may only narrow the code-owned remote ceiling. */
21
+ remote?: boolean;
19
22
  validate?: (result: ExternalCliPreflightResult) => void;
20
23
  }
21
24
 
@@ -90,7 +93,7 @@ function narrowPositiveInteger(value: number | undefined, ceiling: number, label
90
93
  }
91
94
 
92
95
  function specKey(spec: ExternalCliPreflightSpec): string {
93
- return JSON.stringify([spec.id, spec.versionArgs, spec.helpArgs, spec.evidenceArgs, spec.evidenceLabel, spec.probeTimeoutMs]);
96
+ return JSON.stringify([spec.id, spec.versionArgs, spec.helpArgs, spec.evidenceArgs, spec.evidenceLabel, spec.probeTimeoutMs, spec.remote === true]);
94
97
  }
95
98
 
96
99
  export function preflightExternalCli(command: string, spec: ExternalCliPreflightSpec, env: NodeJS.ProcessEnv, cwd?: string): ExternalCliPreflightResult {
@@ -99,7 +102,8 @@ export function preflightExternalCli(command: string, spec: ExternalCliPreflight
99
102
  const lookupKey = JSON.stringify([binaryPath, binaryMtimeMs, specKey(spec)]);
100
103
  const cachedKey = lookup.get(lookupKey);
101
104
  const cached = cachedKey ? cache.get(cachedKey) : undefined;
102
- const probeTimeoutMs = narrowPositiveInteger(spec.probeTimeoutMs, MAX_PROBE_TIMEOUT_MS, "probeTimeoutMs");
105
+ const ceiling = spec.remote === true ? MAX_REMOTE_PROBE_TIMEOUT_MS : MAX_PROBE_TIMEOUT_MS;
106
+ const probeTimeoutMs = narrowPositiveInteger(spec.probeTimeoutMs, ceiling, "probeTimeoutMs");
103
107
  const base = cached ?? {
104
108
  binaryPath,
105
109
  binaryMtimeMs,
@@ -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
+ }