pi-subagents 0.65.0 → 0.65.1

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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +16 -0
  4. package/docs/extension-api.md +3 -0
  5. package/docs/tool-reference.md +8 -2
  6. package/docs/workflows.md +8 -0
  7. package/package.json +3 -1
  8. package/runner-server-preload.mjs +13 -0
  9. package/skills/pi-subagents/SKILL.md +2 -1
  10. package/skills/pi-subagents/references/execution-controls.md +5 -1
  11. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  12. package/src/api/preflight.ts +5 -1
  13. package/src/extension/config.ts +4 -2
  14. package/src/extension/index.ts +31 -2
  15. package/src/extension/schemas.ts +1 -1
  16. package/src/extension/tool-description.ts +5 -1
  17. package/src/integrations/pi-web-session-liveness.ts +73 -0
  18. package/src/intercom/native-supervisor-channel.ts +22 -36
  19. package/src/intercom/supervisor-ui.ts +3 -2
  20. package/src/missions/workflow-state.ts +37 -16
  21. package/src/runs/background/async-execution.ts +8 -1
  22. package/src/runs/background/async-resume.ts +3 -1
  23. package/src/runs/background/async-retention.ts +9 -0
  24. package/src/runs/background/notify.ts +2 -0
  25. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  26. package/src/runs/background/run-child-session.ts +2 -1
  27. package/src/runs/background/runner-aliases.ts +32 -5
  28. package/src/runs/background/subagent-runner.ts +19 -0
  29. package/src/runs/foreground/execution.ts +15 -1
  30. package/src/runs/foreground/foreground-history.ts +3 -1
  31. package/src/runs/foreground/prompt-audit.ts +9 -5
  32. package/src/runs/foreground/subagent-executor.ts +61 -34
  33. package/src/runs/shared/acceptance.ts +14 -1
  34. package/src/runs/shared/child-session.ts +13 -18
  35. package/src/runs/shared/llm-intent-arbiter.ts +20 -11
  36. package/src/runs/shared/model-exclusions.ts +2 -1
  37. package/src/runs/shared/model-fallback.ts +31 -2
  38. package/src/runs/shared/nested-events.ts +3 -3
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  41. package/src/runs/shared/worktree.ts +75 -10
  42. package/src/shared/model-response-aliases.ts +13 -0
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/utils.ts +3 -0
  45. package/src/shared/watch-strategy.ts +2 -0
  46. package/src/tui/fleet-status.ts +1 -1
  47. package/src/tui/render.ts +21 -10
  48. package/src/workflows/scripted-workflow.ts +32 -6
  49. package/src/workflows/workflow-checklist.ts +4 -3
@@ -17,6 +17,7 @@ export interface SupervisorRequestMessageDetails {
17
17
  childIndex?: number;
18
18
  childTarget?: string;
19
19
  interview?: unknown;
20
+ requestBody?: string;
20
21
  replyHint?: string;
21
22
  }
22
23
 
@@ -107,7 +108,7 @@ function optionalString(value: unknown): boolean {
107
108
 
108
109
  function requestDetails(value: unknown): SupervisorRequestMessageDetails | undefined {
109
110
  if (!isRecord(value)) return undefined;
110
- if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
111
+ if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.requestBody) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
111
112
  if (value.reason !== undefined && !isSupervisorReason(value.reason)) return undefined;
112
113
  if (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") return undefined;
113
114
  if (value.childIndex !== undefined && (typeof value.childIndex !== "number" || !Number.isFinite(value.childIndex))) return undefined;
@@ -153,7 +154,7 @@ function requestLines(message: SupervisorMessageLike, details: SupervisorRequest
153
154
  if (details.childTarget) lines.push(`Child target: ${boundedField(details.childTarget)}`);
154
155
  lines.push(`Request ID: ${requestId}`);
155
156
  if (details.expectsReply) lines.push(`Reply with: ${displayText(details.replyHint ?? supervisorReplyHint(requestId), MAX_BODY_CHARS, expanded)}`);
156
- lines.push("", "Request:", displayText(contentText(message.content) || "(no request body)", MAX_BODY_CHARS, expanded));
157
+ lines.push("", "Request:", displayText((details.requestBody ?? contentText(message.content)) || "(no request body)", MAX_BODY_CHARS, expanded));
157
158
  if (details.interview !== undefined) lines.push("", "Interview shape:", interviewText(details.interview, expanded));
158
159
  return lines;
159
160
  }
@@ -18,6 +18,12 @@ export interface MissionWorkflowState {
18
18
  set(key: string, value: unknown): void;
19
19
  }
20
20
 
21
+ export interface MissionWorkflowStateOptions {
22
+ isProcessAlive?: (pid: number) => boolean;
23
+ getProcessStartKey?: (pid: number) => string | undefined;
24
+ retryDelaysMs?: readonly number[];
25
+ }
26
+
21
27
  export function missionStatePath(location: MissionStoreLocation, missionId: string): string {
22
28
  return path.join(location.missionDir, validateMissionId(missionId), "state.json");
23
29
  }
@@ -66,13 +72,22 @@ function windowsProcessStartKey(pid: number): string | undefined {
66
72
  }
67
73
  }
68
74
 
75
+ let currentProcessKey: string | undefined | null = null;
76
+
69
77
  function processStartKey(pid: number): string | undefined {
78
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
79
+ if (pid !== process.pid) return computeProcessStartKey(pid);
80
+ if (currentProcessKey === null) currentProcessKey = computeProcessStartKey(pid);
81
+ return currentProcessKey;
82
+ }
83
+
84
+ function computeProcessStartKey(pid: number): string | undefined {
70
85
  if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
71
86
  if (process.platform === "win32") return windowsProcessStartKey(pid);
72
87
  return undefined;
73
88
  }
74
89
 
75
- const CURRENT_PROCESS_KEY = processStartKey(process.pid);
90
+ type StateLockOptions = Required<MissionWorkflowStateOptions>;
76
91
 
77
92
  function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
78
93
  try {
@@ -91,13 +106,13 @@ function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
91
106
  return undefined;
92
107
  }
93
108
 
94
- function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
109
+ function stateLockIsStale(lockPath: string, options: StateLockOptions, now = Date.now()): boolean {
95
110
  const owner = readStateLockOwner(lockPath);
96
111
  if (owner) {
97
- if (!isProcessAlive(owner.pid)) return true;
112
+ if (!options.isProcessAlive(owner.pid)) return true;
98
113
  if (owner.processKey) {
99
- const currentProcessKey = owner.pid === process.pid ? CURRENT_PROCESS_KEY : processStartKey(owner.pid);
100
- if (currentProcessKey) return owner.processKey !== currentProcessKey;
114
+ const observedProcessKey = options.getProcessStartKey(owner.pid);
115
+ if (observedProcessKey) return owner.processKey !== observedProcessKey;
101
116
  if (owner.pid === process.pid) return true;
102
117
  }
103
118
  return false;
@@ -140,11 +155,11 @@ function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
140
155
  waitForFileSystemRetry(delayMs);
141
156
  }
142
157
 
143
- function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
144
- if (!stateLockIsStale(lockPath)) return false;
158
+ function reclaimStaleStateLock(lockPath: string, reclaimPath: string, options: StateLockOptions): boolean {
159
+ if (!stateLockIsStale(lockPath, options)) return false;
145
160
  if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
146
161
  try {
147
- if (!stateLockIsStale(lockPath)) return false;
162
+ if (!stateLockIsStale(lockPath, options)) return false;
148
163
  fs.rmSync(lockPath, { recursive: true, force: true });
149
164
  return true;
150
165
  } finally {
@@ -152,7 +167,7 @@ function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
152
167
  }
153
168
  }
154
169
 
155
- function withStateFileLock<T>(filePath: string, operation: () => T): T {
170
+ function withStateFileLock<T>(filePath: string, operation: () => T, options: StateLockOptions): T {
156
171
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
157
172
  const lockPath = `${filePath}.lock`;
158
173
  const reclaimPath = `${lockPath}.reclaim`;
@@ -163,7 +178,7 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
163
178
  fs.rmSync(reclaimPath, { recursive: true, force: true });
164
179
  continue;
165
180
  }
166
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
181
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
167
182
  continue;
168
183
  }
169
184
  let acquired = false;
@@ -171,17 +186,18 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
171
186
  acquired = tryMakeDirectory(lockPath, 0o700);
172
187
  } catch (error) {
173
188
  if (isRetryableFileSystemError(error)) {
174
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
189
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
175
190
  continue;
176
191
  }
177
192
  throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
178
193
  }
179
194
  if (!acquired) {
180
- if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
- waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
195
+ if (reclaimStaleStateLock(lockPath, reclaimPath, options)) continue;
196
+ waitForStateLock(options.retryDelaysMs[attempt], lockPath);
182
197
  continue;
183
198
  }
184
- owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
199
+ const key = options.getProcessStartKey(process.pid);
200
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(key ? { processKey: key } : {}) };
185
201
  try {
186
202
  fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
187
203
  } catch (error) {
@@ -205,8 +221,13 @@ function validateStateKey(value: unknown): string {
205
221
  return value;
206
222
  }
207
223
 
208
- export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string): MissionWorkflowState {
224
+ export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string, options: MissionWorkflowStateOptions = {}): MissionWorkflowState {
209
225
  const filePath = missionStatePath(location, missionId);
226
+ const lockOptions: StateLockOptions = {
227
+ isProcessAlive: options.isProcessAlive ?? isProcessAlive,
228
+ getProcessStartKey: options.getProcessStartKey ?? processStartKey,
229
+ retryDelaysMs: options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS,
230
+ };
210
231
  let loaded = false;
211
232
  let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
212
233
 
@@ -254,7 +275,7 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
254
275
  writePrivateAtomicJson(filePath, next);
255
276
  values = next;
256
277
  loaded = true;
257
- });
278
+ }, lockOptions);
258
279
  },
259
280
  };
260
281
  }
@@ -146,6 +146,7 @@ interface AsyncExecutionContext {
146
146
  currentModel?: ParentModel;
147
147
  /** Optional model-scope enforcement resolved from subagent settings. */
148
148
  modelScope?: ModelScopeConfig;
149
+ modelResponseAliases?: Record<string, string[]>;
149
150
  /** Whether the parent session has an interactive UI. */
150
151
  interactive?: boolean;
151
152
  /** The executor's own child runtime when the launch comes from an in-process child. */
@@ -586,7 +587,10 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Om
586
587
  stdoutFd = fs.openSync(logPaths.stdoutPath, "a");
587
588
  stderrFd = fs.openSync(logPaths.stderrPath, "a");
588
589
  }
589
- const proc = spawn(nodeCommand, [jitiCliPath, runner, cfgPath], {
590
+ const preload = hostPeerAliases.supplemental.length > 0
591
+ ? ["--import", new URL("../../../runner-server-preload.mjs", import.meta.url).href]
592
+ : [];
593
+ const proc = spawn(nodeCommand, [...preload, jitiCliPath, runner, cfgPath], {
590
594
  cwd,
591
595
  ...backgroundProcessOptions(),
592
596
  stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
@@ -1021,6 +1025,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
1021
1025
  modelCandidates: externalRunner ? undefined : modelCandidates,
1022
1026
  ...(primaryModelFromParent ? { skipPrimaryModelVerification: true } : {}),
1023
1027
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1028
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1024
1029
  tools: a.tools,
1025
1030
  excludeTools: a.excludeTools,
1026
1031
  allowNestedSubagents: a.allowNestedSubagents,
@@ -1822,6 +1827,7 @@ export function executeAsyncSingle(
1822
1827
  });
1823
1828
  const recoveryAgentConfig = params.recoveryAgentConfig ?? agentConfig;
1824
1829
  const recoveryDescriptor: SteeringRecoveryDescriptor = {
1830
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1825
1831
  version: 1,
1826
1832
  ...(lane ? { lane } : {}),
1827
1833
  launchContractDigest,
@@ -1910,6 +1916,7 @@ export function executeAsyncSingle(
1910
1916
  modelCandidates,
1911
1917
  ...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
1912
1918
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1919
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1913
1920
  tools: agentConfig.tools,
1914
1921
  excludeTools: agentConfig.excludeTools,
1915
1922
  allowNestedSubagents: agentConfig.allowNestedSubagents,
@@ -15,6 +15,7 @@ import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/paral
15
15
  import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
16
16
  import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
17
17
  import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
18
+ import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
18
19
 
19
20
  export interface AsyncResumeParams {
20
21
  id?: string;
@@ -317,7 +318,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
317
318
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
318
319
  const parsed = value as Record<string, unknown>;
319
320
  const allowedFields = new Set([
320
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
321
+ "modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
321
322
  "subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
322
323
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
323
324
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
@@ -337,6 +338,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
337
338
  } catch (error) {
338
339
  throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
339
340
  }
341
+ validateModelResponseAliases(parsed.modelResponseAliases, `async recovery descriptor '${descriptorPath}' modelResponseAliases`);
340
342
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
341
343
  if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
342
344
  if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
@@ -320,7 +320,16 @@ function readCursor(root: string): RetentionCursor {
320
320
  return value?.version === 1 ? value as unknown as RetentionCursor : { version: 1 };
321
321
  }
322
322
 
323
+ let currentProcessStartIdentity: string | undefined | null = null;
324
+
323
325
  function processStartIdentity(pid: number): string | undefined {
326
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
327
+ if (pid !== process.pid) return computeProcessStartIdentity(pid);
328
+ if (currentProcessStartIdentity === null) currentProcessStartIdentity = computeProcessStartIdentity(pid);
329
+ return currentProcessStartIdentity;
330
+ }
331
+
332
+ function computeProcessStartIdentity(pid: number): string | undefined {
324
333
  if (process.platform === "linux") {
325
334
  try {
326
335
  const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
@@ -123,6 +123,7 @@ export interface RegisterSubagentNotifyOptions {
123
123
 
124
124
  export interface CompletionNotifier {
125
125
  deliver(result: CompletionNotification): Promise<boolean>;
126
+ hasPendingDelivery(): boolean;
126
127
  dispose(): void;
127
128
  }
128
129
 
@@ -628,6 +629,7 @@ export default function registerSubagentNotify(
628
629
 
629
630
  return {
630
631
  deliver,
632
+ hasPendingDelivery: () => pending.size > 0,
631
633
  dispose() {
632
634
  if (disposed) return;
633
635
  disposed = true;
@@ -0,0 +1,96 @@
1
+ import * as fs from "node:fs";
2
+ import type { SubagentState } from "../../shared/types.ts";
3
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
4
+ import { hasLiveNestedDescendants, projectNestedEvents } from "../shared/nested-events.ts";
5
+
6
+ interface RetainedNestedRouteTrackerOptions {
7
+ pollIntervalMs?: number;
8
+ platform?: NodeJS.Platform;
9
+ watch?: typeof fs.watch;
10
+ }
11
+
12
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
13
+ const REFRESH_DEBOUNCE_MS = 25;
14
+
15
+ export function createRetainedNestedRouteTracker(
16
+ state: Pick<SubagentState, "retainedForegroundNestedRoutes">,
17
+ options: RetainedNestedRouteTrackerOptions = {},
18
+ ): { track: (rootRunId: string) => void; clear: () => void } {
19
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
20
+ const watch = options.watch ?? fs.watch;
21
+ const watchers = new Map<string, fs.FSWatcher>();
22
+ const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
23
+ let poller: ReturnType<typeof setInterval> | undefined;
24
+
25
+ const close = (rootRunId: string): void => {
26
+ watchers.get(rootRunId)?.close();
27
+ watchers.delete(rootRunId);
28
+ const timer = refreshTimers.get(rootRunId);
29
+ if (timer) clearTimeout(timer);
30
+ refreshTimers.delete(rootRunId);
31
+ };
32
+
33
+ const refresh = (rootRunId: string): void => {
34
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
35
+ if (!retained) {
36
+ close(rootRunId);
37
+ return;
38
+ }
39
+ try {
40
+ if (hasLiveNestedDescendants(projectNestedEvents(retained).children)) return;
41
+ state.retainedForegroundNestedRoutes?.delete(rootRunId);
42
+ close(rootRunId);
43
+ } catch (error) {
44
+ console.error(`Failed to refresh retained nested descendants for foreground run '${rootRunId}':`, error);
45
+ }
46
+ };
47
+
48
+ const scheduleRefresh = (rootRunId: string): void => {
49
+ if (refreshTimers.has(rootRunId)) return;
50
+ const timer = setTimeout(() => {
51
+ refreshTimers.delete(rootRunId);
52
+ refresh(rootRunId);
53
+ }, REFRESH_DEBOUNCE_MS);
54
+ timer.unref?.();
55
+ refreshTimers.set(rootRunId, timer);
56
+ };
57
+
58
+ const ensurePoller = (): void => {
59
+ if (poller || (state.retainedForegroundNestedRoutes?.size ?? 0) === 0) return;
60
+ poller = setInterval(() => {
61
+ for (const rootRunId of state.retainedForegroundNestedRoutes?.keys() ?? []) refresh(rootRunId);
62
+ if ((state.retainedForegroundNestedRoutes?.size ?? 0) > 0) return;
63
+ if (poller) clearInterval(poller);
64
+ poller = undefined;
65
+ }, pollIntervalMs);
66
+ poller.unref?.();
67
+ };
68
+
69
+ const track = (rootRunId: string): void => {
70
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
71
+ if (!retained) return;
72
+ if (shouldUseNativeFsWatch("retained-nested-route-tracker", options.platform) && !watchers.has(rootRunId)) {
73
+ try {
74
+ const watcher = watch(retained.eventSink, () => scheduleRefresh(rootRunId));
75
+ watcher.on("error", () => close(rootRunId));
76
+ watcher.unref?.();
77
+ watchers.set(rootRunId, watcher);
78
+ } catch {
79
+ // The bounded safety poll below covers unsupported or failed watchers.
80
+ }
81
+ }
82
+ scheduleRefresh(rootRunId);
83
+ ensurePoller();
84
+ };
85
+
86
+ const clear = (): void => {
87
+ if (poller) clearInterval(poller);
88
+ poller = undefined;
89
+ for (const rootRunId of watchers.keys()) close(rootRunId);
90
+ for (const timer of refreshTimers.values()) clearTimeout(timer);
91
+ refreshTimers.clear();
92
+ state.retainedForegroundNestedRoutes?.clear();
93
+ };
94
+
95
+ return { track, clear };
96
+ }
@@ -93,6 +93,7 @@ export interface RunChildSessionInput {
93
93
  runDeadlineAt?: number;
94
94
  expectedModelForVerification?: string;
95
95
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
96
+ modelResponseAliases?: Record<string, string[]>;
96
97
  mutationTools?: readonly string[];
97
98
  }
98
99
 
@@ -468,7 +469,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
468
469
  if (event.message.model) {
469
470
  model = event.message.model;
470
471
  if (input.expectedModelForVerification && !hasToolCall) {
471
- const modelVerificationError = formatSubagentModelVerificationError(input.expectedModelForVerification, event.message.model, input.modelVerificationRegistry);
472
+ const modelVerificationError = formatSubagentModelVerificationError(input.expectedModelForVerification, event.message.model, input.modelVerificationRegistry, input.modelResponseAliases);
472
473
  if (modelVerificationError && !error) error = modelVerificationError;
473
474
  }
474
475
  }
@@ -7,11 +7,12 @@
7
7
  * this extension and are not installed next to it. pi's own extension loader
8
8
  * aliases those specifiers to the copies shipped inside the installed pi
9
9
  * package; the parent computes the same map and hands it to the runner
10
- * through `JITI_ALIAS`, so the runner's child sessions and hooks share one
11
- * copy of every host package.
10
+ * through `JITI_ALIAS`, so child sessions and hooks retain host API identity.
11
+ * Only Pi 0.85.0's missing server exports may come from this extension.
12
12
  */
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
15
16
 
16
17
  export const JITI_ALIAS_ENV = "JITI_ALIAS";
17
18
 
@@ -19,6 +20,12 @@ export const JITI_ALIAS_ENV = "JITI_ALIAS";
19
20
  export const HOST_PEER_ALIASES: ReadonlyArray<{ specifier: string; pkg: string; subpath: string }> = [
20
21
  { specifier: "@earendil-works/pi-coding-agent", pkg: "@earendil-works/pi-coding-agent", subpath: "." },
21
22
  { specifier: "@earendil-works/pi-agent-core", pkg: "@earendil-works/pi-agent-core", subpath: "." },
23
+ { specifier: "@earendil-works/pi-agent-core/node", pkg: "@earendil-works/pi-agent-core", subpath: "./node" },
24
+ { specifier: "@earendil-works/chord", pkg: "@earendil-works/chord", subpath: "." },
25
+ { specifier: "@earendil-works/chord/context", pkg: "@earendil-works/chord", subpath: "./context" },
26
+ { specifier: "@earendil-works/pi-server", pkg: "@earendil-works/pi-server", subpath: "." },
27
+ { specifier: "@earendil-works/pi-server/unix", pkg: "@earendil-works/pi-server", subpath: "./unix" },
28
+ { specifier: "@earendil-works/pi-client/unix", pkg: "@earendil-works/pi-client", subpath: "./unix" },
22
29
  { specifier: "@earendil-works/pi-tui", pkg: "@earendil-works/pi-tui", subpath: "." },
23
30
  { specifier: "@earendil-works/pi-ai", pkg: "@earendil-works/pi-ai", subpath: "./compat" },
24
31
  { specifier: "@earendil-works/pi-ai/compat", pkg: "@earendil-works/pi-ai", subpath: "./compat" },
@@ -31,6 +38,7 @@ export const HOST_PEER_ALIASES: ReadonlyArray<{ specifier: string; pkg: string;
31
38
 
32
39
  interface PackageManifest {
33
40
  name?: unknown;
41
+ version?: unknown;
34
42
  main?: unknown;
35
43
  exports?: unknown;
36
44
  }
@@ -112,14 +120,33 @@ export function findHostPeerPackageDir(piPackageRoot: string, pkg: string): stri
112
120
  }
113
121
 
114
122
  /** The alias map the runner needs, or the specifiers that could not be resolved. */
115
- export function resolveHostPeerAliases(piPackageRoot: string): { aliases: Record<string, string>; missing: string[] } {
123
+ export function resolveHostPeerAliases(
124
+ piPackageRoot: string,
125
+ extensionRoot = fileURLToPath(new URL("../../../", import.meta.url)),
126
+ ): { aliases: Record<string, string>; missing: string[]; supplemental: string[] } {
116
127
  const aliases: Record<string, string> = {};
117
128
  const missing: string[] = [];
129
+ const supplemental: string[] = [];
118
130
  for (const { specifier, pkg, subpath } of HOST_PEER_ALIASES) {
119
131
  const packageDir = findHostPeerPackageDir(piPackageRoot, pkg);
120
- const target = packageDir ? resolvePackageSubpath(packageDir, subpath) : undefined;
132
+ let target = packageDir ? resolvePackageSubpath(packageDir, subpath) : undefined;
133
+ // Pi 0.85.0 omitted this runtime dependency. Never replace working host
134
+ // exports or extend this exact version contract to other peers/hosts.
135
+ if ((!target || !fs.existsSync(target))
136
+ && (specifier === "@earendil-works/pi-server" || specifier === "@earendil-works/pi-server/unix")
137
+ && readManifest(piPackageRoot)?.version === "0.85.0"
138
+ && (!packageDir || readManifest(packageDir)?.version === "0.85.0")) {
139
+ const localDir = findHostPeerPackageDir(extensionRoot, pkg);
140
+ if (localDir && readManifest(localDir)?.version === "0.85.0") {
141
+ const localTarget = resolvePackageSubpath(localDir, subpath);
142
+ if (localTarget && fs.existsSync(localTarget)) {
143
+ target = localTarget;
144
+ supplemental.push(specifier);
145
+ }
146
+ }
147
+ }
121
148
  if (target && fs.existsSync(target)) aliases[specifier] = target;
122
149
  else missing.push(specifier);
123
150
  }
124
- return { aliases, missing };
151
+ return { aliases, missing, supplemental };
125
152
  }
@@ -1,8 +1,26 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
2
3
  import * as fs from "node:fs";
4
+ import { createRequire } from "node:module";
3
5
  import * as path from "node:path";
4
6
  import { pathToFileURL } from "node:url";
5
7
  import type { Message } from "@earendil-works/pi-ai";
8
+
9
+ // Detached runners skip Pi's CLI proxy setup. Keep fetch on the same Undici dispatcher.
10
+ function ensureProxyAwareHttpDispatcher(): void {
11
+ try {
12
+ // SAFETY: require loads the pinned direct dependency described by these types.
13
+ const undici = createRequire(import.meta.url)("undici") as typeof import("undici");
14
+ const dispatcher = new undici.EnvHttpProxyAgent({ allowH2: false });
15
+ // Fetch rejects stream errors; the listener prevents an unhandled EventEmitter error.
16
+ EventEmitter.prototype.on.call(dispatcher, "error", () => {});
17
+ undici.setGlobalDispatcher(dispatcher);
18
+ undici.install();
19
+ } catch (error) {
20
+ console.error(`[pi-subagents] proxy-aware HTTP dispatcher not installed: ${error instanceof Error ? error.message : String(error)}`);
21
+ }
22
+ }
23
+ ensureProxyAwareHttpDispatcher();
6
24
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
25
  import { writeAsyncResultFile, writePendingAsyncResultFile } from "./result-files.ts";
8
26
  import { createFileCoalescer } from "../../shared/file-coalescer.ts";
@@ -1188,6 +1206,7 @@ async function runSingleStepInner(
1188
1206
  runDeadlineAt: ctx.deadlineAt,
1189
1207
  expectedModelForVerification,
1190
1208
  modelVerificationRegistry: step.modelVerificationRegistry,
1209
+ modelResponseAliases: step.modelResponseAliases,
1191
1210
  mutationTools: step.mutationTools,
1192
1211
  }));
1193
1212
  const toolDiagnostic = run.exitCode === 0 && !run.error ? launch.capture.toolDiagnostic() : undefined;
@@ -983,6 +983,20 @@ async function runSingleAttempt(
983
983
  compactionStartedReceived = false;
984
984
  afterCompactionSettlement = false;
985
985
  }
986
+ if (evt.type === "agent_start") {
987
+ const diagnostic = capture.toolDiagnostic();
988
+ if (diagnostic) {
989
+ const message = formatChildToolDiagnostic(diagnostic, { host: "parent" });
990
+ toolAvailabilityError = message;
991
+ result.error = message;
992
+ result.finalOutput = message;
993
+ progress.status = "failed";
994
+ progress.error = message;
995
+ fireUpdate();
996
+ abortChild();
997
+ return;
998
+ }
999
+ }
986
1000
  const lifecycleAction = projectChildLifecycle(evt, false, childLifecycleState);
987
1001
  if (evt.type === "agent_settled" && lifecycleAction === "start-drain") {
988
1002
  agentSettledReceived = true;
@@ -1086,7 +1100,7 @@ async function runSingleAttempt(
1086
1100
  progress.model = evt.message.model;
1087
1101
  if (!result.model) result.model = evt.message.model;
1088
1102
  if (expectedModelForVerification && !hasToolCall) {
1089
- const modelVerificationError = formatSubagentModelVerificationError(expectedModelForVerification, evt.message.model, options.availableModels);
1103
+ const modelVerificationError = formatSubagentModelVerificationError(expectedModelForVerification, evt.message.model, options.availableModels, options.modelResponseAliases);
1090
1104
  if (modelVerificationError && !result.error) result.error = modelVerificationError;
1091
1105
  }
1092
1106
  }
@@ -5,6 +5,7 @@ import { DIRS } from "../../shared/types.ts";
5
5
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
6
6
  import { utf8Tail } from "../../shared/utf8.ts";
7
7
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
8
+ import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
8
9
 
9
10
  export const MAX_REMEMBERED_FOREGROUND_RUNS = 50;
10
11
  const HISTORY_VERSION = 1;
@@ -71,11 +72,12 @@ function isRestorableResumeContract(value: unknown): boolean {
71
72
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
72
73
  try {
73
74
  if (Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_RESUME_CONTRACT_BYTES) return false;
75
+ validateModelResponseAliases((value as ForegroundResumeChild["resumeContract"])?.modelResponseAliases);
74
76
  } catch {
75
77
  return false;
76
78
  }
77
79
  const contract = value as NonNullable<ForegroundResumeChild["resumeContract"]>;
78
- if (Object.keys(contract).some((key) => !["outputSchema", "agentContract", "acceptance", "output", "outputMode"].includes(key))) return false;
80
+ if (Object.keys(contract).some((key) => !["modelResponseAliases", "outputSchema", "agentContract", "acceptance", "output", "outputMode"].includes(key))) return false;
79
81
  if (contract.outputSchema !== undefined && (!contract.outputSchema || typeof contract.outputSchema !== "object" || Array.isArray(contract.outputSchema))) return false;
80
82
  if (contract.agentContract !== undefined && (!contract.agentContract || typeof contract.agentContract !== "object" || Array.isArray(contract.agentContract) || contract.agentContract.version !== 1)) return false;
81
83
  if (validateAcceptanceInput(contract.acceptance).length > 0) return false;
@@ -1,7 +1,6 @@
1
- import { Agent, type StreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
2
- import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { streamSimple } from "@earendil-works/pi-ai/compat";
4
- import type { Model, ProviderHeaders } from "@earendil-works/pi-ai";
1
+ import type { Agent, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ProviderHeaders } from "@earendil-works/pi-ai";
5
4
  import { agentStreamOptions } from "../../shared/agent-stream-options.ts";
6
5
  import type { ForegroundRunControl } from "../../shared/types.ts";
7
6
  export type PromptAuditView = "authored" | "runtime" | "effective";
@@ -21,7 +20,7 @@ export interface LivePromptAudit {
21
20
 
22
21
  const livePrompts = new WeakMap<ForegroundRunControl, Map<number, LivePromptAudit>>();
23
22
 
24
- type RegistryModel = Model<any>;
23
+ type RegistryModel = NonNullable<ExtensionContext["model"]>;
25
24
 
26
25
  function fullModelId(model: Pick<RegistryModel, "provider" | "id">): string {
27
26
  return `${model.provider}/${model.id}`;
@@ -64,6 +63,11 @@ export async function rewritePromptWithGuidance(input: {
64
63
  }): Promise<string> {
65
64
  const model = input.ctx.model;
66
65
  if (!model) throw new Error("Prompt redo needs the current session model to rewrite the authored task.");
66
+ const [{ Agent }, { convertToLlm }, { streamSimple }] = await Promise.all([
67
+ import("@earendil-works/pi-agent-core"),
68
+ import("@earendil-works/pi-coding-agent"),
69
+ import("@earendil-works/pi-ai/compat"),
70
+ ]);
67
71
  const auth = await resolveRewriteAuth(input.ctx, model);
68
72
  const registeredProvider = (input.ctx.modelRegistry as {
69
73
  getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;