pi-crew 0.11.11 → 0.11.13

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 (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import type { AgentConfig } from "../../agents/agent-config.ts";
2
3
  import { resolveToolPolicy } from "../../agents/agent-config.ts";
3
4
  import type { CrewRuntimeConfig } from "../../config/config.ts";
@@ -55,6 +56,7 @@ import {
55
56
  import { applyLiveAgentControlRequest, applyLiveAgentControlRequests, type LiveAgentControlCursor } from "./live-agent-control.ts";
56
57
  import {
57
58
  disposeLiveAgentSession,
59
+ dropLiveAgentReference,
58
60
  listLiveAgents,
59
61
  markLiveAgentCompleted,
60
62
  registerLiveAgent,
@@ -168,6 +170,21 @@ export interface LiveSessionPlannedResult {
168
170
 
169
171
  type LiveSessionModule = Record<string, unknown> & {
170
172
  createAgentSession?: (options?: Record<string, unknown>) => Promise<{ session: LiveSessionLike; modelFallbackMessage?: string }>;
173
+ /**
174
+ * U4 fast path: build the expensive services (ModelRuntime +
175
+ * SettingsManager + reloaded ResourceLoader) ONCE per (cwd, mcp-mode,
176
+ * context) and fan out cheap per-task sessions from it via
177
+ * createAgentSessionFromServices. Measured on pi 1.0.4/1.1.0: services
178
+ * ≈224ms one-time, each child session 0.5–1.5ms vs ≈2240ms for the old
179
+ * full reload-per-worker path (probe /tmp/pi-crew-embed-probe/).
180
+ */
181
+ createAgentSessionServices?: (options?: Record<string, unknown>) => Promise<Record<string, unknown>>;
182
+ createAgentSessionFromServices?: (options?: Record<string, unknown>) => Promise<{
183
+ session: LiveSessionLike;
184
+ modelFallbackMessage?: string;
185
+ }>;
186
+ /** Supplies the built-in mcp extension factory for MCP-permitted roles. */
187
+ createMcpExtension?: (options?: Record<string, unknown>) => unknown;
171
188
  DefaultResourceLoader?: new (options: Record<string, unknown>) => { reload?: () => Promise<void> };
172
189
  SessionManager?: {
173
190
  inMemory?: (cwd?: string) => unknown;
@@ -192,8 +209,184 @@ type LiveSessionLike = {
192
209
  modelRuntime?: {
193
210
  registerNativeProvider?: (provider: unknown) => void;
194
211
  };
212
+ /** U4 recursion guard (layer 1): public SDK accessor for loaded extension paths. */
213
+ extensionRunner?: { getExtensionPaths?: () => string[] };
195
214
  };
196
215
 
216
+ /**
217
+ * U4 — shared live-session services bundle.
218
+ *
219
+ * `createAgentSessionServices` performs the expensive one-time work
220
+ * (ModelRuntime.create + settings + DefaultResourceLoader.reload with full
221
+ * extension/skill/theme discovery). Bundles are cached per (cwd, agentDir,
222
+ * mcp-permission, context-files) — the axes that change WHAT the loader
223
+ * discovers — so the first live worker in a mode pays the bundle cost and
224
+ * every subsequent worker in the same mode creates its session in ~1ms.
225
+ */
226
+ interface LiveSessionServicesBundle {
227
+ cwd: string;
228
+ agentDir: string;
229
+ mcpPermitted: boolean;
230
+ loadContextFiles: boolean;
231
+ /** SDK AgentSessionServices ({cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics}). */
232
+ services: Record<string, unknown>;
233
+ resourceLoader: Record<string, unknown>;
234
+ createdAtMs: number;
235
+ }
236
+
237
+ /** Cap: each bundle holds a ModelRuntime + loader (~15MB heap) — bound the cache. */
238
+ const MAX_LIVE_SESSION_SERVICES_BUNDLES = 8;
239
+
240
+ const liveSessionServicesCache = new Map<string, Promise<LiveSessionServicesBundle>>();
241
+
242
+ function liveSessionServicesCacheKey(cwd: string, agentDir: string, mcpPermitted: boolean, loadContextFiles: boolean): string {
243
+ return [pathResolve(cwd), pathResolve(agentDir), mcpPermitted ? "mcp" : "nomcp", loadContextFiles ? "ctx" : "noctx"].join("|");
244
+ }
245
+
246
+ function pathResolve(input: string): string {
247
+ try {
248
+ return path.resolve(input);
249
+ } catch {
250
+ return input;
251
+ }
252
+ }
253
+
254
+ /** Test seam: inspect the services bundle cache. */
255
+ export function liveSessionServicesCacheStatsForTest(): { size: number; keys: string[] } {
256
+ return { size: liveSessionServicesCache.size, keys: [...liveSessionServicesCache.keys()] };
257
+ }
258
+
259
+ /** Test seam: drop all cached bundles (hermetic tests must not share bundles). */
260
+ export function clearLiveSessionServicesCacheForTest(): void {
261
+ liveSessionServicesCache.clear();
262
+ }
263
+
264
+ /**
265
+ * Get-or-build the shared services bundle. Failures delete the cache slot so a
266
+ * later task can retry the build instead of latching a rejected promise.
267
+ */
268
+ async function getOrCreateLiveSessionServices(
269
+ mod: LiveSessionModule,
270
+ input: { cwd: string; agentDir: string; mcpPermitted: boolean; loadContextFiles: boolean },
271
+ ): Promise<LiveSessionServicesBundle> {
272
+ const key = liveSessionServicesCacheKey(input.cwd, input.agentDir, input.mcpPermitted, input.loadContextFiles);
273
+ let entry = liveSessionServicesCache.get(key);
274
+ if (!entry) {
275
+ if (liveSessionServicesCache.size >= MAX_LIVE_SESSION_SERVICES_BUNDLES) {
276
+ // FIFO eviction — bundles are stateless services, evicting only drops
277
+ // the warm cache (a later task rebuilds).
278
+ const oldest = liveSessionServicesCache.keys().next().value;
279
+ if (oldest !== undefined) liveSessionServicesCache.delete(oldest);
280
+ }
281
+ entry = (async () => {
282
+ const services = await mod.createAgentSessionServices!({
283
+ cwd: input.cwd,
284
+ agentDir: input.agentDir,
285
+ resourceLoaderOptions: {
286
+ // U4 recursion kill switch (layer 0, loader level): NO discovered/
287
+ // settings/package extensions load in the child — so the pi-crew
288
+ // host extension itself cannot load+initialize inside a worker.
289
+ noExtensions: true,
290
+ noPromptTemplates: true,
291
+ noThemes: true,
292
+ noContextFiles: !input.loadContextFiles,
293
+ ...(input.mcpPermitted && typeof mod.createMcpExtension === "function"
294
+ ? {
295
+ // G2: MCP-permitted (write-capable) roles inherit the parent's
296
+ // MCP servers via the built-in mcp extension ONLY — pinned
297
+ // inline, nothing else loads alongside it.
298
+ extensionFactories: [
299
+ {
300
+ name: "mcp",
301
+ factory: mod.createMcpExtension(),
302
+ builtin: true,
303
+ replaceable: true,
304
+ },
305
+ ],
306
+ additionalExtensionPaths: ["builtin:mcp"],
307
+ }
308
+ : {}),
309
+ },
310
+ });
311
+ const resourceLoader = asRecord(services?.resourceLoader);
312
+ if (!resourceLoader) throw new Error("createAgentSessionServices returned no resourceLoader");
313
+ return {
314
+ cwd: input.cwd,
315
+ agentDir: input.agentDir,
316
+ mcpPermitted: input.mcpPermitted,
317
+ loadContextFiles: input.loadContextFiles,
318
+ services,
319
+ resourceLoader,
320
+ createdAtMs: Date.now(),
321
+ };
322
+ })();
323
+ entry.catch(() => liveSessionServicesCache.delete(key));
324
+ liveSessionServicesCache.set(key, entry);
325
+ }
326
+ return entry;
327
+ }
328
+
329
+ /**
330
+ * U4 — per-task delegating ResourceLoader.
331
+ *
332
+ * The shared bundle's loader is reloaded once, but the SYSTEM prompt is
333
+ * per-task (liveSystemPrompt embeds runId/taskId/role/agent). The SDK's
334
+ * systemPromptOverride is a loader-level option evaluated at reload() time,
335
+ * so a shared loader would latch the FIRST task's prompt. This wrapper
336
+ * delegates all reload-derived state (extensions/skills/prompts/themes) to
337
+ * the shared loader while serving per-task system-prompt values — the
338
+ * AgentSession reads the prompt through getSystemPrompt() at build time.
339
+ */
340
+ export function buildPerTaskResourceLoader(
341
+ sharedLoader: Record<string, unknown>,
342
+ overrides: { systemPrompt: string },
343
+ ): Record<string, unknown> {
344
+ const delegate = <T>(method: string, fallback: T): ((...args: unknown[]) => T) => {
345
+ const fn = sharedLoader[method];
346
+ return typeof fn === "function" ? (fn as (...args: unknown[]) => T).bind(sharedLoader) : () => fallback;
347
+ };
348
+ const emptyExtensions = { extensions: [], errors: [], warnings: [], runtime: undefined };
349
+ const emptySkillDiag = { skills: [], diagnostics: [] };
350
+ const emptyPromptDiag = { prompts: [], diagnostics: [] };
351
+ const emptyThemeDiag = { themes: [], diagnostics: [] };
352
+ const emptyAgentsFiles = { agentsFiles: [] };
353
+ return {
354
+ getExtensions: delegate("getExtensions", emptyExtensions),
355
+ getSkills: delegate("getSkills", emptySkillDiag),
356
+ getPrompts: delegate("getPrompts", emptyPromptDiag),
357
+ getThemes: delegate("getThemes", emptyThemeDiag),
358
+ getAgentsFiles: delegate("getAgentsFiles", emptyAgentsFiles),
359
+ getSystemPrompt: () => overrides.systemPrompt,
360
+ getSystemPromptSource: () => undefined,
361
+ getAppendSystemPrompt: delegate("getAppendSystemPrompt", [] as string[]),
362
+ getAppendSystemPromptSources: delegate("getAppendSystemPromptSources", [] as unknown[]),
363
+ extendResources: delegate("extendResources", undefined),
364
+ reload: delegate("reload", Promise.resolve()) as unknown as (options?: unknown) => Promise<void>,
365
+ };
366
+ }
367
+
368
+ /** Extension paths the U4 loader is allowed to load per MCP permission. */
369
+ export function allowedLiveExtensionPaths(mcpPermitted: boolean): string[] {
370
+ return mcpPermitted ? ["builtin:mcp"] : [];
371
+ }
372
+
373
+ /**
374
+ * U4 recursion guard layer 1 (post-creation assert, evals harness pattern):
375
+ * list extension paths that loaded but were not sanctioned. `filterActiveTools`
376
+ * remains layer 2 — it only prunes the ACTIVE tool set, it does not unload
377
+ * extension code.
378
+ */
379
+ export function collectForeignExtensions(session: LiveSessionLike | undefined, allowedPaths: readonly string[]): string[] {
380
+ const runner = session?.extensionRunner;
381
+ if (typeof runner?.getExtensionPaths !== "function") return [];
382
+ try {
383
+ const allowed = new Set(allowedPaths);
384
+ return runner.getExtensionPaths().filter((extensionPath: string) => !allowed.has(extensionPath));
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+
197
390
  function appendTranscript(filePath: string | undefined, event: unknown): void {
198
391
  if (!filePath) return;
199
392
  // Task 26 (2026-08-24): serialize + redact at queue time, then route
@@ -537,6 +730,13 @@ export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
537
730
  function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?: string): void {
538
731
  if (typeof session.getActiveToolNames !== "function" || typeof session.setActiveToolsByName !== "function") return;
539
732
  const recursiveTools = new Set(["team", "Team", "Agent", "get_subagent_result", "steer_subagent"]);
733
+ // U4 hung-tool mitigation (a) "builtin-tools-only": hard floor on the
734
+ // active set — built-in tools (bash honors AbortSignal via killProcessTree),
735
+ // MCP tools for MCP-permitted roles, and pi-crew's own custom tools. Any
736
+ // other tool name (extension drift the loader-level guard missed) is
737
+ // dropped from the ACTIVE set here (layer 2).
738
+ const builtinFloor = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", "submit_result", "irc"]);
739
+ const mcpPermitted = mcpPermittedForRole(role);
540
740
  // F1 unify (v0.8.0): use the shared resolveToolPolicy so this path agrees
541
741
  // with child-pi (pi-args.ts). Before this, live-session used frontmatter
542
742
  // only and ignored role-config entirely — so a builtin explorer on the
@@ -547,7 +747,13 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
547
747
  const allowed = policy.tools?.length ? new Set(policy.tools) : undefined;
548
748
  const active = session
549
749
  .getActiveToolNames()
550
- .filter((name) => !recursiveTools.has(name) && !disallowed?.has(name) && (!allowed || allowed.has(name)));
750
+ .filter(
751
+ (name) =>
752
+ !recursiveTools.has(name) &&
753
+ !disallowed?.has(name) &&
754
+ (!allowed || allowed.has(name)) &&
755
+ (builtinFloor.has(name) || (mcpPermitted && name.startsWith("mcp__"))),
756
+ );
551
757
  session.setActiveToolsByName(active);
552
758
  }
553
759
 
@@ -565,14 +771,44 @@ function usageFromStats(stats: unknown): UsageState | undefined {
565
771
  : undefined;
566
772
  }
567
773
 
568
- async function promptWithTimeout(session: LiveSessionLike, text: string, timeoutMs: number, label: string): Promise<boolean> {
774
+ /**
775
+ * U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
776
+ * NOT settle even after the cooperative session.abort() + grace window — i.e.
777
+ * a tool or provider call is holding the turn and ignoring the AbortSignal
778
+ * (in-process abort is cooperative-only; see agent-loop.ts:798-830).
779
+ */
780
+ export class LiveSessionHungToolError extends Error {
781
+ constructor(
782
+ label: string,
783
+ readonly timeoutMs: number,
784
+ readonly graceMs: number,
785
+ ) {
786
+ super(
787
+ `${label} hung: prompt did not settle within ${graceMs}ms after the ${timeoutMs}ms timeout and cooperative abort — dropping session reference and failing the task (escape hatch).`,
788
+ );
789
+ this.name = "LiveSessionHungToolError";
790
+ }
791
+ }
792
+
793
+ async function promptWithTimeout(
794
+ session: LiveSessionLike,
795
+ text: string,
796
+ timeoutMs: number,
797
+ label: string,
798
+ hungToolGraceMs = DEFAULT_LIVE_SESSION.hungToolGraceMs,
799
+ ): Promise<boolean> {
569
800
  // CORE-11: pass an AbortSignal into session.prompt so the underlying prompt is
570
801
  // genuinely cancelled on timeout instead of being abandoned by Promise.race.
802
+ // U4: streamingBehavior is MANDATORY on every prompt() — without it a prompt
803
+ // landing while the session is already streaming throws "Agent is already
804
+ // processing" (pi agent-session.js:1548). "followUp" queues the message to
805
+ // run after the in-flight turn instead of erroring.
571
806
  const ac = new AbortController();
572
807
  const promptPromise = session.prompt?.(text, {
573
808
  source: "api",
574
809
  expandPromptTemplates: false,
575
810
  signal: ac.signal,
811
+ streamingBehavior: "followUp",
576
812
  });
577
813
  if (!promptPromise) return false;
578
814
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -588,6 +824,27 @@ async function promptWithTimeout(session: LiveSessionLike, text: string, timeout
588
824
  }),
589
825
  ]);
590
826
  return true;
827
+ } catch (error) {
828
+ const messageText = error instanceof Error ? error.message : String(error);
829
+ if (messageText.includes("timed out")) {
830
+ // U4 escape hatch: abort() is cooperative — a tool that ignores the
831
+ // AbortSignal keeps the prompt() promise pending forever. Wait a bounded
832
+ // grace for a settle; if none arrives, surface the distinct hung-tool
833
+ // error so the caller can drop the session reference and fail the task
834
+ // instead of leaking a stuck slot.
835
+ const settledEarly = await Promise.race([
836
+ promptPromise.then(
837
+ () => true,
838
+ () => true,
839
+ ),
840
+ new Promise<boolean>((resolve) => {
841
+ const graceTimer = setTimeout(() => resolve(false), hungToolGraceMs);
842
+ graceTimer.unref?.();
843
+ }),
844
+ ]);
845
+ if (!settledEarly) throw new LiveSessionHungToolError(label, timeoutMs, hungToolGraceMs);
846
+ }
847
+ throw error;
591
848
  } finally {
592
849
  if (timer) clearTimeout(timer);
593
850
  }
@@ -630,6 +887,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
630
887
 
631
888
  const isCurrent = input.isCurrent ?? (() => true);
632
889
  let streamOut: StreamingOutputHandle | undefined;
890
+ // U4 escape hatch state: set when a timed-out prompt failed to settle even
891
+ // after cooperative abort — the finally block then TERMINATES (abort +
892
+ // dispose + unregister) the handle instead of the soft dispose, so a
893
+ // tool/provider call holding the event loop cannot keep a registry slot.
894
+ let hungToolEscalated = false;
633
895
 
634
896
  // G1: Capture yield result from custom tool callback
635
897
  let customToolYieldResult: YieldResult | undefined;
@@ -650,11 +912,14 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
650
912
  ],
651
913
  },
652
914
  };
653
- const mockSession = {
654
- steer: async () => undefined,
655
- prompt: async () => undefined,
656
- abort: async () => undefined,
657
- };
915
+ // U4 (D4 fix): the mock session deliberately exposes NO steer/prompt/abort —
916
+ // a steer/followUp arriving in the planning window (between this mock
917
+ // registration and the real session's registerLiveAgent for the same
918
+ // agentId) must be QUEUED on the handle (steerLiveAgent falls back to
919
+ // pendingSteers) so registerLiveAgent can route it to the real session on
920
+ // re-registration. The previous no-op {steer,prompt,abort} stub silently
921
+ // swallowed those messages (lost, not doubled).
922
+ const mockSession = {};
658
923
  registerLiveAgent(
659
924
  {
660
925
  agentId,
@@ -692,6 +957,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
692
957
  if (isCurrent()) input.onOutput?.(stdout);
693
958
  updateLiveAgentStatus(agentId, "completed");
694
959
  markLiveAgentCompleted(agentId);
960
+ // U4 dispose normalization: the mock handle is terminal too — drain any
961
+ // pending steer/followUp that arrived during the mock window and mark the
962
+ // handle terminated so later steers are rejected clearly instead of
963
+ // queueing forever against a mock that will never run again.
964
+ disposeLiveAgentSession(agentId);
695
965
  return {
696
966
  available: true,
697
967
  exitCode: 0,
@@ -767,6 +1037,39 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
767
1037
  // loader: the MCP extension(s) are dropped from the child's extension
768
1038
  // list for non-permitted roles (no MCP code load, no server connect).
769
1039
  const mcpPermitted = mcpPermittedForRole(input.task.role);
1040
+ // U4 fast path availability: shared services bundle + fromServices. Any
1041
+ // missing export (foreign/older SDK) falls back to the legacy per-worker
1042
+ // DefaultResourceLoader path below, unchanged.
1043
+ const sharedServicesAvailable =
1044
+ Boolean(agentDir) &&
1045
+ typeof mod.createAgentSessionServices === "function" &&
1046
+ typeof mod.createAgentSessionFromServices === "function" &&
1047
+ typeof mod.SessionManager?.inMemory === "function";
1048
+ let sharedBundle: LiveSessionServicesBundle | undefined;
1049
+ if (sharedServicesAvailable) {
1050
+ // U4: one services bundle per (cwd, agentDir, mcp-mode, context-files).
1051
+ // The bundle's loader is reloaded ONCE; per-task system prompts are
1052
+ // served by a delegating wrapper (see buildPerTaskResourceLoader) at
1053
+ // session creation, so nothing per-task latches into the shared bundle.
1054
+ sharedBundle = await getOrCreateLiveSessionServices(mod, {
1055
+ cwd: input.task.cwd,
1056
+ agentDir: agentDir!,
1057
+ mcpPermitted,
1058
+ loadContextFiles: input.runtimeConfig?.inheritContext === true,
1059
+ });
1060
+ // G2 degraded-enforcement warning (fast path): MCP-permitted role but
1061
+ // the SDK no longer exports createMcpExtension → the builtin:mcp pin
1062
+ // cannot be armed; surface the fail-open instead of drifting silently.
1063
+ if (mcpPermitted && typeof mod.createMcpExtension !== "function") {
1064
+ appendEventFireAndForget(input.manifest.eventsPath, {
1065
+ type: "task.mcp_enforcement_degraded",
1066
+ runId: input.manifest.runId,
1067
+ taskId: input.task.id,
1068
+ message: `G2 MCP policy NOT enforced for role "${input.task.role ?? "unknown"}" (U4 fast path): pi SDK does not export createMcpExtension — this worker will NOT inherit the parent's MCP servers.`,
1069
+ data: { role: input.task.role ?? null, reason: "createMcpExtension-missing" },
1070
+ });
1071
+ }
1072
+ }
770
1073
  let resourceLoader: unknown;
771
1074
  // F1 (v0.7.9) NOTE: `agent.excludeExtensions` is applied on the
772
1075
  // child-pi path (see `pi-args.ts`). The live-session path loads
@@ -775,7 +1078,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
775
1078
  // v0.7.9, the denylist is honored on the default async path only;
776
1079
  // the live-session path (opt-in via `runtime.preferLiveSession`)
777
1080
  // ignores it. This is a documented limitation, not a silent bug.
778
- if (mod.DefaultResourceLoader && agentDir) {
1081
+ if (!sharedServicesAvailable && mod.DefaultResourceLoader && agentDir) {
779
1082
  resourceLoader = new mod.DefaultResourceLoader({
780
1083
  cwd: input.task.cwd,
781
1084
  agentDir,
@@ -794,10 +1097,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
794
1097
  // stopped exporting DefaultResourceLoader, or agentDir unresolvable),
795
1098
  // enforcement silently degrades to fail-open — surface it instead of
796
1099
  // letting a version drift quietly re-expose the parent's MCP servers.
797
- const g2DegradationReason = g2EnforcementDegradationReason({
798
- mcpPermitted,
799
- resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
800
- });
1100
+ const g2DegradationReason = sharedServicesAvailable
1101
+ ? undefined
1102
+ : g2EnforcementDegradationReason({
1103
+ mcpPermitted,
1104
+ resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
1105
+ });
801
1106
  if (g2DegradationReason) {
802
1107
  appendEventFireAndForget(input.manifest.eventsPath, {
803
1108
  type: "task.mcp_enforcement_degraded",
@@ -864,27 +1169,71 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
864
1169
  const customTools = [submitResultTool, ircTool];
865
1170
 
866
1171
  const sessionCreateStart = Date.now();
867
- const created = await mod.createAgentSession({
868
- cwd: input.task.cwd,
869
- ...(agentDir ? { agentDir } : {}),
870
- ...(resourceLoader ? { resourceLoader } : {}),
871
- ...(mod.SessionManager?.inMemory
872
- ? {
873
- sessionManager: mod.SessionManager.inMemory(input.task.cwd),
874
- }
875
- : {}),
876
- ...(mod.SettingsManager?.create && agentDir
877
- ? {
878
- settingsManager: mod.SettingsManager.create(input.task.cwd, agentDir),
879
- }
880
- : {}),
881
- ...(resolvedModel ? { model: resolvedModel } : {}),
882
- ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
883
- // R3-12: cycling/visibility scope = the fallback chain (see helper).
884
- ...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
885
- customTools,
886
- });
1172
+ let created: { session: LiveSessionLike; modelFallbackMessage?: string };
1173
+ if (sharedBundle) {
1174
+ // U4 fast path: createAgentSessionFromServices fans a cheap child
1175
+ // session out of the shared bundle. The per-task system prompt rides
1176
+ // a delegating loader wrapper (the shared loader's systemPromptOverride
1177
+ // latches at reload time and cannot vary per task).
1178
+ created = await mod.createAgentSessionFromServices!({
1179
+ services: {
1180
+ ...sharedBundle.services,
1181
+ resourceLoader: buildPerTaskResourceLoader(sharedBundle.resourceLoader, {
1182
+ systemPrompt: liveSystemPrompt(input),
1183
+ }),
1184
+ },
1185
+ sessionManager: mod.SessionManager!.inMemory!(input.task.cwd),
1186
+ ...(resolvedModel ? { model: resolvedModel } : {}),
1187
+ ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
1188
+ // R3-12: cycling/visibility scope = the fallback chain (see helper).
1189
+ ...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
1190
+ customTools,
1191
+ });
1192
+ } else {
1193
+ created = await mod.createAgentSession({
1194
+ cwd: input.task.cwd,
1195
+ ...(agentDir ? { agentDir } : {}),
1196
+ ...(resourceLoader ? { resourceLoader } : {}),
1197
+ ...(mod.SessionManager?.inMemory
1198
+ ? {
1199
+ sessionManager: mod.SessionManager.inMemory(input.task.cwd),
1200
+ }
1201
+ : {}),
1202
+ ...(mod.SettingsManager?.create && agentDir
1203
+ ? {
1204
+ settingsManager: mod.SettingsManager.create(input.task.cwd, agentDir),
1205
+ }
1206
+ : {}),
1207
+ ...(resolvedModel ? { model: resolvedModel } : {}),
1208
+ ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
1209
+ // R3-12: cycling/visibility scope = the fallback chain (see helper).
1210
+ ...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
1211
+ customTools,
1212
+ });
1213
+ }
887
1214
  session = created.session;
1215
+ // U4 recursion guard layer 1 (assert, evals harness pattern): the child
1216
+ // session must have loaded ONLY sanctioned extensions — nothing for
1217
+ // non-MCP roles, exactly `builtin:mcp` for MCP-permitted ones. A
1218
+ // violation means the loader-level kill switch (noExtensions) failed and
1219
+ // the pi-crew host extension (recursion risk) or a foreign extension
1220
+ // loaded inside the worker → fail the task loudly. filterActiveTools
1221
+ // remains layer 2 for tool-surface drift.
1222
+ if (sharedBundle) {
1223
+ const foreignExtensions = collectForeignExtensions(session, allowedLiveExtensionPaths(mcpPermitted));
1224
+ if (foreignExtensions.length > 0) {
1225
+ appendEventFireAndForget(input.manifest.eventsPath, {
1226
+ type: "live-session.foreign_extension_blocked",
1227
+ runId: input.manifest.runId,
1228
+ taskId: input.task.id,
1229
+ message: `U4 recursion guard: child session loaded unsanctioned extension(s) ${foreignExtensions.join(", ")} — failing task.`,
1230
+ data: { foreignExtensions, allowed: allowedLiveExtensionPaths(mcpPermitted) },
1231
+ });
1232
+ throw new Error(
1233
+ `live-session recursion guard: child session loaded unsanctioned extension(s): ${foreignExtensions.join(", ")}`,
1234
+ );
1235
+ }
1236
+ }
888
1237
  // U3: register the test-only provider override (faux E2E seam) before
889
1238
  // anything streams. No-op in production (modelProviderOverride stays
890
1239
  // undefined there).
@@ -898,6 +1247,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
898
1247
  data: {
899
1248
  elapsedMs: Date.now() - sessionCreateStart,
900
1249
  modelFallbackMessage: created.modelFallbackMessage,
1250
+ // U4: true = child session came from the shared services bundle
1251
+ // (fast path, ~ms); false = legacy full loader reload (~2.2s).
1252
+ sharedServices: Boolean(sharedBundle),
901
1253
  },
902
1254
  });
903
1255
  filterActiveTools(session, input.agent, input.task.role);
@@ -1125,11 +1477,14 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1125
1477
  },
1126
1478
  });
1127
1479
 
1128
- // Phase 3: Wrap session.prompt with timeout for graceful cancellation
1129
- const sessionTimeoutMs = DEFAULT_LIVE_SESSION.responseTimeoutMs;
1480
+ // Phase 3: Wrap session.prompt with timeout for graceful cancellation.
1481
+ // U4: runtime.liveSession.responseTimeoutMs / hungToolGraceMs override the
1482
+ // defaults (DEFAULT_LIVE_SESSION) for testability and operator control.
1483
+ const sessionTimeoutMs = input.runtimeConfig?.liveSession?.responseTimeoutMs ?? DEFAULT_LIVE_SESSION.responseTimeoutMs;
1484
+ const hungToolGraceMs = input.runtimeConfig?.liveSession?.hungToolGraceMs ?? DEFAULT_LIVE_SESSION.hungToolGraceMs;
1130
1485
  try {
1131
1486
  await liveAgentContext.run({ agentId, modelRef: resolvedModelRef ?? "" }, () =>
1132
- promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session"),
1487
+ promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session", hungToolGraceMs),
1133
1488
  );
1134
1489
  } catch (promptError) {
1135
1490
  const msg = promptError instanceof Error ? promptError.message : String(promptError);
@@ -1197,8 +1552,10 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1197
1552
  schemaReminder,
1198
1553
  Math.min(sessionTimeoutMs, DEFAULT_LIVE_SESSION.idleWaitTimeoutMs),
1199
1554
  "Live-session schema reminder",
1555
+ hungToolGraceMs,
1200
1556
  );
1201
- } catch {
1557
+ } catch (schemaError) {
1558
+ if (schemaError instanceof LiveSessionHungToolError) throw schemaError;
1202
1559
  /* ignore */
1203
1560
  }
1204
1561
  await new Promise((resolve) => setTimeout(resolve, DEFAULT_LIVE_SESSION.yieldPollIntervalMs));
@@ -1239,8 +1596,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1239
1596
  reminder,
1240
1597
  Math.min(sessionTimeoutMs, DEFAULT_LIVE_SESSION.idleWaitTimeoutMs),
1241
1598
  "Live-session yield reminder",
1599
+ hungToolGraceMs,
1242
1600
  );
1243
- } catch {
1601
+ } catch (reminderError) {
1602
+ // U4: a hung reminder prompt propagates the escape hatch to the outer
1603
+ // handler; ordinary reminder failures just end the reminder loop.
1604
+ if (reminderError instanceof LiveSessionHungToolError) throw reminderError;
1244
1605
  break;
1245
1606
  } finally {
1246
1607
  // Restore previous tools even if reminder prompt times out/throws.
@@ -1298,6 +1659,32 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1298
1659
  },
1299
1660
  };
1300
1661
  } catch (error) {
1662
+ // U4 hung-tool escape hatch (drop-reference + fail task): a timed-out
1663
+ // prompt that ignored the cooperative abort can never settle — emit the
1664
+ // dedicated event, terminate (abort + dispose + unregister) the handle,
1665
+ // and fail the task WITHOUT awaiting the abandoned prompt promise.
1666
+ if (error instanceof LiveSessionHungToolError) {
1667
+ hungToolEscalated = true;
1668
+ appendEventFireAndForget(input.manifest.eventsPath, {
1669
+ type: "live-session.hung_tool_escape",
1670
+ runId: input.manifest.runId,
1671
+ taskId: input.task.id,
1672
+ message: `Hung tool/provider escape hatch: ${error.message}`,
1673
+ data: { timeoutMs: error.timeoutMs, graceMs: error.graceMs, agentId },
1674
+ });
1675
+ updateLiveAgentStatus(agentId, "failed");
1676
+ // drop-reference (synchronous — never awaits the stuck abort): drain +
1677
+ // terminate + dispose + UNREGISTER, so the hung session holds no slot.
1678
+ dropLiveAgentReference(agentId);
1679
+ return {
1680
+ available: true,
1681
+ exitCode: 1,
1682
+ stdout: stdoutTail.value().trim(),
1683
+ stderr: error.message,
1684
+ jsonEvents,
1685
+ error: error.message,
1686
+ };
1687
+ }
1301
1688
  const message = error instanceof Error ? error.message : String(error);
1302
1689
 
1303
1690
  // Phase 8: Log diagnostics on failure
@@ -1341,11 +1728,19 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1341
1728
  if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);
1342
1729
  if (controlTimer) clearInterval(controlTimer);
1343
1730
  streamOut?.close();
1344
- if (input.signal?.aborted) {
1731
+ if (hungToolEscalated) {
1732
+ // U4 hung-tool escape (drop-reference): synchronous removal only —
1733
+ // terminateLiveAgent awaits session.abort() which can itself hang on
1734
+ // a stuck session. Idempotent no-op when the outer catch already dropped.
1735
+ dropLiveAgentReference(agentId);
1736
+ } else if (input.signal?.aborted) {
1345
1737
  await terminateLiveAgent(agentId, "cancelled", appendEvent, input.manifest.eventsPath);
1346
1738
  } else {
1347
- // Dispose the session to free resources, but keep the handle in the registry
1348
- // for resume/follow-up. Removing the handle entirely breaks steer/followUp/resume.
1739
+ // U4 dispose normalization: dispose the session AND make the handle
1740
+ // terminal (drain pendingSteers/pendingFollowUps, reject later steers
1741
+ // clearly). The handle stays in the registry for status/health/UI —
1742
+ // but steer/followUp/resume on it now fail fast instead of silently
1743
+ // queueing into the disposed session.
1349
1744
  disposeLiveAgentSession(agentId);
1350
1745
  }
1351
1746