pi-crew 0.11.22 → 0.11.24

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/agents/explorer.md +3 -3
  3. package/agents/librarian.md +3 -3
  4. package/dist/index.mjs +523 -520
  5. package/docs/dynamic-workflows.md +14 -2
  6. package/package.json +4 -4
  7. package/src/agents/discover-agents.ts +8 -2
  8. package/src/config/config-validation.ts +3 -1
  9. package/src/config/env-allowlist-registry.ts +5 -0
  10. package/src/config/env-vars.ts +9 -6
  11. package/src/config/role-tools.ts +7 -6
  12. package/src/extension/management.ts +7 -0
  13. package/src/extension/register.ts +15 -0
  14. package/src/extension/run-index.ts +24 -1
  15. package/src/extension/team-tool/destructive-gate.ts +208 -0
  16. package/src/extension/team-tool/goal-wrap.ts +11 -1
  17. package/src/extension/team-tool/goal.ts +13 -1
  18. package/src/extension/team-tool/respond.ts +8 -6
  19. package/src/extension/team-tool/run.ts +4 -4
  20. package/src/extension/team-tool.ts +10 -0
  21. package/src/observability/exporters/otlp-exporter.ts +11 -1
  22. package/src/prompt/prompt-runtime.ts +102 -18
  23. package/src/prompt/surface-worker.ts +13 -1
  24. package/src/runtime/broker/crew-broker-client.ts +7 -0
  25. package/src/runtime/broker/crew-broker.ts +113 -152
  26. package/src/runtime/broker/protocol/connection-state.ts +159 -1
  27. package/src/runtime/broker/protocol/events-replay.ts +50 -10
  28. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  29. package/src/runtime/child-pi/child-pi-constants.ts +9 -0
  30. package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
  31. package/src/runtime/child-pi/child-pi-streams.ts +19 -3
  32. package/src/runtime/child-pi/postmortem-registry.ts +8 -2
  33. package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
  34. package/src/runtime/compaction/tool-output-pruner.ts +20 -2
  35. package/src/runtime/crew-agent-records.ts +92 -17
  36. package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
  37. package/src/runtime/deadletter.ts +5 -1
  38. package/src/runtime/dispatch-batch.ts +60 -16
  39. package/src/runtime/effectiveness.ts +24 -1
  40. package/src/runtime/finalize-run.ts +12 -1
  41. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
  42. package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
  43. package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
  44. package/src/runtime/live-session/live-agent-control.ts +34 -14
  45. package/src/runtime/live-session/live-agent-manager.ts +20 -5
  46. package/src/runtime/live-session/live-session-runtime.ts +66 -81
  47. package/src/runtime/live-session/usage-bridge.ts +66 -2
  48. package/src/runtime/mcp-proxy.ts +10 -0
  49. package/src/runtime/model/model-fallback.ts +68 -0
  50. package/src/runtime/model/runtime-policy.ts +20 -6
  51. package/src/runtime/model/runtime-resolver.ts +5 -3
  52. package/src/runtime/orphan-worker-registry.ts +64 -24
  53. package/src/runtime/pi-spawn.ts +4 -1
  54. package/src/runtime/process/zombie-scanner.ts +6 -2
  55. package/src/runtime/recovery/crash-recovery.ts +35 -2
  56. package/src/runtime/scheduling/batch-barrier.ts +42 -5
  57. package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
  58. package/src/runtime/scheduling/scheduler.ts +74 -29
  59. package/src/runtime/stale-reconciler.ts +45 -6
  60. package/src/runtime/surface/herdr-provider.ts +54 -1
  61. package/src/runtime/surface/launch-script.ts +7 -1
  62. package/src/runtime/surface/resolve-surface.ts +4 -1
  63. package/src/runtime/surface/tmux-provider.ts +32 -2
  64. package/src/runtime/task-runner/child-executor.ts +3 -2
  65. package/src/runtime/workspace-lock.ts +52 -4
  66. package/src/state/contracts.ts +2 -0
  67. package/src/state/coordination/process-lock.ts +50 -0
  68. package/src/state/event-log/cursor.ts +24 -3
  69. package/src/state/event-log/event-log-rotation.ts +26 -2
  70. package/src/state/event-log/worker-atomic-writer.ts +5 -0
  71. package/src/state/stores/active-run-registry.ts +56 -5
  72. package/src/state/stores/ownership-map.ts +26 -7
  73. package/src/state/stores/plan-store.ts +30 -1
  74. package/src/state/stores/sqlite-run-state.ts +91 -1
  75. package/src/state/types.ts +5 -0
  76. package/src/ui/dashboard-panes/agents-pane.ts +4 -3
  77. package/src/ui/dashboard-panes/plan-pane.ts +5 -3
  78. package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
  79. package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
  80. package/src/ui/run-dashboard.ts +6 -4
  81. package/src/ui/run-snapshot-cache.ts +3 -2
  82. package/src/ui/settings-overlay.ts +68 -10
  83. package/src/ui/theme-discovery.ts +26 -7
  84. package/src/ui/transcript-viewer.ts +5 -3
  85. package/src/utils/visual.ts +84 -0
@@ -549,6 +549,21 @@ export function clearLiveAgentsForTest(): void {
549
549
  invalidateSortedLiveAgents();
550
550
  }
551
551
 
552
+ /**
553
+ * R2-M-B3 (Wave-3): fence UNTRUSTED peer message content before it enters a
554
+ * recipient session. Peer DMs/broadcasts cross trust tiers (e.g. an
555
+ * explorer-mapped worker injecting into a writer session) and were previously
556
+ * delivered with only a `[DM from X]` prefix — raw, unfenced. Wrap in an
557
+ * `<untrusted-peer-message>` fence and strip any literal closing-tag
558
+ * occurrences from BOTH the content and the sender id so worker-authored
559
+ * text cannot escape the fence and pose as session-authored instructions.
560
+ */
561
+ function fencePeerMessage(from: string, content: string): string {
562
+ const safeFrom = String(from).split("</untrusted-peer-message").join("");
563
+ const safeContent = String(content).split("</untrusted-peer-message").join("");
564
+ return `<untrusted-peer-message from="${safeFrom}">\n${safeContent}\n</untrusted-peer-message>`;
565
+ }
566
+
552
567
  /** Phase 7/G4: Send an IRC message to a specific live agent (DM).
553
568
  * Uses non-blocking delivery via sendCustomMessage when available.
554
569
  * Falls back to session.prompt (blocking) when not.
@@ -569,7 +584,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
569
584
  (session.sendCustomMessage as (msg: unknown, opts?: unknown) => void)(
570
585
  {
571
586
  customType: "irc",
572
- content: `[DM from ${message.from}] ${message.content}`,
587
+ content: `[DM from ${message.from}] ${fencePeerMessage(message.from, message.content)}`,
573
588
  display: "collapsed",
574
589
  },
575
590
  { deliverAs: "followUp", triggerTurn: false },
@@ -581,7 +596,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
581
596
  }
582
597
  // Fallback: inject as prompt (blocking)
583
598
  if (typeof handle.session.prompt === "function") {
584
- const ircPrompt = `[Message from ${message.from}] ${message.content}`;
599
+ const ircPrompt = `[Message from ${message.from}] ${fencePeerMessage(message.from, message.content)}`;
585
600
  void handle.session
586
601
  .prompt(ircPrompt, { source: "api", expandPromptTemplates: false, streamingBehavior: "followUp" })
587
602
  .catch((error) => logInternalError("live-agent-manager.irc-deliver", error, `agentId=${handle.agentId}`));
@@ -610,7 +625,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
610
625
  (session.sendCustomMessage as (msg: unknown, opts?: unknown) => void)(
611
626
  {
612
627
  customType: "irc",
613
- content: `[Broadcast from ${message.from}] ${message.content}`,
628
+ content: `[Broadcast from ${message.from}] ${fencePeerMessage(message.from, message.content)}`,
614
629
  display: "collapsed",
615
630
  },
616
631
  { deliverAs: "followUp", triggerTurn: false },
@@ -623,7 +638,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
623
638
  }
624
639
  // Fallback: inject as prompt
625
640
  if (typeof handle.session.prompt === "function") {
626
- const ircPrompt = `[Broadcast from ${message.from}] ${message.content}`;
641
+ const ircPrompt = `[Broadcast from ${message.from}] ${fencePeerMessage(message.from, message.content)}`;
627
642
  void handle.session
628
643
  .prompt(ircPrompt, {
629
644
  source: "api",
@@ -706,7 +721,7 @@ export async function respondAsBackground(
706
721
 
707
722
  // --- Non-blocking delivery -------------------------------------------
708
723
  const session = handle.session as Record<string, unknown>;
709
- const deliveredTag = `[DM from ${fromId}] ${message}`;
724
+ const deliveredTag = `[DM from ${fromId}] ${fencePeerMessage(fromId, message)}`;
710
725
  let delivered = false;
711
726
  if (typeof session.sendCustomMessage === "function") {
712
727
  try {
@@ -13,7 +13,7 @@ import type { WorkflowStep } from "../../workflows/workflow-config.ts";
13
13
  import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
14
14
  import { createIrcTool } from "../custom-tools/irc-tool.ts";
15
15
  import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
16
- import { g2EnforcementDegradationReason, mcpPermittedForRole, stripMcpExtensions } from "../mcp-proxy.ts";
16
+ import { g2EnforcementDegradationReason, mcpPermittedForRole, shouldFailClosedMcpEnforcement, stripMcpExtensions } from "../mcp-proxy.ts";
17
17
  import {
18
18
  availableModelInfosFromRegistry,
19
19
  buildConfiguredModelRouting,
@@ -87,7 +87,16 @@ import {
87
87
  import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
88
88
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
89
89
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
90
- import { type SessionStatsLike, trackUsageFromMessageEndEvent, usageFromSessionStats } from "./usage-bridge.ts";
90
+ import {
91
+ asRecord,
92
+ eventText,
93
+ extractToolName,
94
+ faultPathUsage,
95
+ finalAssistantText,
96
+ type SessionStatsLike,
97
+ trackUsageFromMessageEndEvent,
98
+ usageFromSessionStats,
99
+ } from "./usage-bridge.ts";
91
100
 
92
101
  /**
93
102
  * Module-scoped latch for the optional peer dependency import. When N
@@ -435,14 +444,6 @@ function appendTranscript(filePath: string | undefined, event: unknown): void {
435
444
  appendBatchedJsonlLine(filePath, `${JSON.stringify(redactSecrets(event))}\n`);
436
445
  }
437
446
 
438
- function asRecord(value: unknown): Record<string, unknown> | undefined {
439
- return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
440
- }
441
-
442
- function isString(value: unknown): value is string {
443
- return typeof value === "string";
444
- }
445
-
446
447
  /**
447
448
  * U3 test seam: register a caller-supplied pi-ai `Provider` on the created
448
449
  * session's ModelRuntime right after creation. Passive by design — no faux
@@ -463,50 +464,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
463
464
  }
464
465
  }
465
466
 
466
- /**
467
- * Type-safe extractor for tool name from streaming events.
468
- * Handles various event structures: { tool: { name } }, { toolName }, { name }.
469
- */
470
- function extractToolName(obj: Record<string, unknown> | undefined): string | undefined {
471
- if (!obj) return undefined;
472
- const tool = asRecord(obj.tool);
473
- if (isString(tool?.name)) return tool.name;
474
- if (isString(obj.toolName)) return obj.toolName;
475
- if (isString(obj.name)) return obj.name;
476
- return undefined;
477
- }
478
-
479
- function textFromContent(content: unknown): string[] {
480
- if (typeof content === "string") return [content];
481
- if (!Array.isArray(content)) return [];
482
- return content.flatMap((part) => {
483
- const obj = asRecord(part);
484
- if (!obj) return [];
485
- if (obj.type === "text" && typeof obj.text === "string") return [obj.text];
486
- if (typeof obj.content === "string") return [obj.content];
487
- return [];
488
- });
489
- }
490
-
491
- function eventText(event: unknown): string[] {
492
- const obj = asRecord(event);
493
- if (!obj) return [];
494
- const text: string[] = [];
495
- if (typeof obj.text === "string") text.push(obj.text);
496
- text.push(...textFromContent(obj.content));
497
- const message = asRecord(obj.message);
498
- if (message) text.push(...textFromContent(message.content));
499
- return text.filter((entry) => entry.trim());
500
- }
501
-
502
- function finalAssistantText(event: unknown): string[] {
503
- const obj = asRecord(event);
504
- if (obj?.type !== "message_end") return [];
505
- const message = asRecord(obj.message);
506
- if (message?.role !== "assistant") return [];
507
- return textFromContent(message.content);
508
- }
509
-
510
467
  /**
511
468
  * F7: resolve the enabledModels allowlist for the current project, but only
512
469
  * if the `reliability.scopeModels` toggle is ON. Returns an empty
@@ -892,25 +849,6 @@ class BoundedSeenControlRequestIdSet extends Set<string> {
892
849
  /** @internal — test seam for the C1 cap (see live-agent-control-dedupe tests). */
893
850
  export const __test__BoundedSeenControlRequestIdSet = BoundedSeenControlRequestIdSet;
894
851
 
895
- /**
896
- * R6-9 (DR-03 round-6): best-effort usage extraction for the fault/watchdog
897
- * escape returns. The success return reports usageFromSessionStats(session),
898
- * but the fault/watchdog catch paths previously carried NO usage — whenever
899
- * embeddedWorkers routed work to the embedded tier and the task faulted, the
900
- * burned turns vanished from the budget (under-count). The session may be
901
- * disposed or partially unusable after the fault, so stats collection is
902
- * guarded — it can never turn a fault into a crash. Returns undefined when no
903
- * usable stats surface exists.
904
- */
905
- function faultPathUsage(session: LiveSessionLike | undefined): UsageState | undefined {
906
- try {
907
- return usageFromSessionStats(session);
908
- } catch (error) {
909
- logInternalError("live-session.fault-usage-stats", error, "session stats unavailable on fault path");
910
- return undefined;
911
- }
912
- }
913
-
914
852
  export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
915
853
  // Cold-start race fix: ensure the hot module graph is warm before touching
916
854
  // any module. Under tsx, concurrent first-imports race module-record
@@ -944,7 +882,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
944
882
  // G1: Capture yield result from custom tool callback
945
883
  let customToolYieldResult: YieldResult | undefined;
946
884
  let customToolYieldResolved = false;
947
- if (getCrewEnv("PI_CREW_MOCK_LIVE_SESSION") === "success") {
885
+ // wave-2b (mock double-gate): the mock session fabricates a success event
886
+ // for the whole run — single-gated, inherited env would silently neuter a
887
+ // worker. Double-gate with PI_CREW_ALLOW_MOCK (mirror mock-fixtures.ts).
888
+ if (
889
+ getCrewEnv("PI_CREW_MOCK_LIVE_SESSION") === "success" &&
890
+ (getCrewEnv("PI_CREW_ALLOW_MOCK") === "1" || getCrewEnv("PI_CREW_ALLOW_MOCK") === "true")
891
+ ) {
948
892
  const agentId = `${input.manifest.runId}:${input.task.id}`;
949
893
  const inherited =
950
894
  input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
@@ -1207,16 +1151,36 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1207
1151
  resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
1208
1152
  });
1209
1153
  if (g2DegradationReason) {
1154
+ // R1-M2 part 2 (Wave-3): FAIL CLOSED. Previously the degradation only
1155
+ // warned and proceeded — a version-drifted SDK silently re-exposed the
1156
+ // parent's MCP servers to a NON-PERMITTED (read-only) role. MCP-permitted
1157
+ // roles keep the warn+proceed behavior (their access is intended); every
1158
+ // other role ABORTS the spawn with a typed error so the drift is loud.
1159
+ const failClosedCode = "mcp-enforcement-fail-closed";
1210
1160
  appendEventFireAndForget(input.manifest.eventsPath, {
1211
- type: "task.mcp_enforcement_degraded",
1161
+ type: mcpPermitted ? "task.mcp_enforcement_degraded" : "task.mcp_enforcement_fail_closed",
1212
1162
  runId: input.manifest.runId,
1213
1163
  taskId: input.task.id,
1214
- message: `G2 MCP policy NOT enforced for role "${input.task.role ?? "unknown"}": pi SDK resource loader unavailable (${g2DegradationReason}) — this worker may inherit the parent's MCP servers.`,
1164
+ message: mcpPermitted
1165
+ ? `G2 MCP policy NOT enforced for role "${input.task.role ?? "unknown"}": pi SDK resource loader unavailable (${g2DegradationReason}) — this worker may inherit the parent's MCP servers.`
1166
+ : `G2 MCP policy ABORTED live-session spawn for role "${input.task.role ?? "unknown"}" (${failClosedCode}): enforcement (MCP extension stripping) could not be armed (${g2DegradationReason}) — refusing fail-open inheritance of the parent's MCP servers.`,
1215
1167
  data: {
1216
1168
  role: input.task.role ?? null,
1217
1169
  reason: g2DegradationReason,
1170
+ code: failClosedCode,
1218
1171
  },
1219
1172
  });
1173
+ if (g2DegradationReason && shouldFailClosedMcpEnforcement(mcpPermitted, g2DegradationReason)) {
1174
+ const message = `[${failClosedCode}] Live-session spawn refused for role "${input.task.role ?? "unknown"}": MCP enforcement degraded (${g2DegradationReason}) and the role is not MCP-permitted — fail-closed instead of inheriting the parent's MCP servers.`;
1175
+ return {
1176
+ available: true,
1177
+ exitCode: 1,
1178
+ stdout: "",
1179
+ stderr: message,
1180
+ jsonEvents: 0,
1181
+ error: message,
1182
+ };
1183
+ }
1220
1184
  }
1221
1185
  const effectiveParentModel = resolveParentModelFromRegistry(input.modelRegistry, input.parentModel);
1222
1186
  const modelRouting = buildConfiguredModelRouting({
@@ -1471,12 +1435,16 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1471
1435
  };
1472
1436
  unsubscribeControlRealtime = subscribeLiveControlRealtime((request) => {
1473
1437
  if (!isCurrent() || request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
1438
+ // R3A-M1: the applier itself now records failures on the request, but a
1439
+ // rejection must never reach unhandledRejection — catch + log here too.
1474
1440
  void applyLiveAgentControlRequest({
1475
1441
  request,
1476
1442
  taskId: input.task.id,
1477
1443
  agentId,
1478
1444
  session,
1479
1445
  seenRequestIds: seenControlRequestIds,
1446
+ }).catch((error) => {
1447
+ logInternalError("live-session.control-realtime-apply", error, `agentId=${agentId}`);
1480
1448
  });
1481
1449
  });
1482
1450
  await pollControl();
@@ -1488,7 +1456,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1488
1456
  // path → keep the faster 500ms cadence.
1489
1457
  controlTimer = setInterval(
1490
1458
  () => {
1491
- if (isCurrent()) void pollControl();
1459
+ // R3A-M1: fire-and-forget poll must never reject unhandled.
1460
+ if (isCurrent())
1461
+ void pollControl().catch((error) => {
1462
+ logInternalError("live-session.control-poll", error, `agentId=${agentId}`);
1463
+ });
1492
1464
  },
1493
1465
  hasLiveControlRealtimeListeners() ? 1000 : 500,
1494
1466
  );
@@ -1525,14 +1497,21 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1525
1497
  trackLiveAgentTurnEnd(agentId);
1526
1498
  if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
1527
1499
  softLimitReached = true;
1528
- void session?.steer?.("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
1500
+ // R3A-M1: rejection here would crash the host session.
1501
+ void session
1502
+ ?.steer?.("You have reached your turn limit. Wrap up immediately — provide your final answer now.")
1503
+ .catch((error) => {
1504
+ logInternalError("live-session.turn-limit-steer", error, `agentId=${agentId}`);
1505
+ });
1529
1506
  } else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + graceTurns) {
1530
1507
  // U11 mitigation (c): abort + bounded watchdog — if the prompt does
1531
1508
  // not settle within the grace, dispose the session (drop-reference,
1532
1509
  // U4 escape discipline) and fail via the escape signal.
1533
1510
  if (embedded && !embeddedWatchdogFired) {
1534
1511
  embeddedWatchdogFired = true;
1535
- void session?.abort?.();
1512
+ void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
1513
+ logInternalError("live-session.watchdog-abort", error, `agentId=${agentId}`);
1514
+ });
1536
1515
  embeddedWatchdogTimer = armTurnWatchdog({
1537
1516
  agentId,
1538
1517
  maxTurns,
@@ -1542,7 +1521,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1542
1521
  escalate: (error: Error) => faultSignalReject?.(error),
1543
1522
  });
1544
1523
  } else {
1545
- void session?.abort?.();
1524
+ void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
1525
+ logInternalError("live-session.watchdog-abort", error, `agentId=${agentId}`);
1526
+ });
1546
1527
  }
1547
1528
  }
1548
1529
  }
@@ -1581,7 +1562,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1581
1562
  }
1582
1563
  // Round 27 (BUG 4): named abort handler (removed in finally below).
1583
1564
  onSignalAbort = (): void => {
1584
- void session?.abort?.();
1565
+ // R3A-M1: abort rejection inside a signal handler must not become an
1566
+ // unhandledRejection — catch + log.
1567
+ void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
1568
+ logInternalError("live-session.signal-abort", error, `agentId=${agentId}`);
1569
+ });
1585
1570
  };
1586
1571
  if (input.signal) {
1587
1572
  if (input.signal.aborted) await session.abort?.();
@@ -1,4 +1,5 @@
1
1
  import type { UsageState } from "../../state/types.ts";
2
+ import { logInternalError } from "../../utils/internal-error.ts";
2
3
  import { usageFromValue } from "../session-usage.ts";
3
4
  import { trackTaskUsage } from "../usage-tracker.ts";
4
5
 
@@ -9,11 +10,11 @@ import { trackTaskUsage } from "../usage-tracker.ts";
9
10
  * ledger — the runtime module keeps only the call sites.
10
11
  */
11
12
 
12
- function asRecord(value: unknown): Record<string, unknown> | undefined {
13
+ export function asRecord(value: unknown): Record<string, unknown> | undefined {
13
14
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
14
15
  }
15
16
 
16
- function isString(value: unknown): value is string {
17
+ export function isString(value: unknown): value is string {
17
18
  return typeof value === "string";
18
19
  }
19
20
 
@@ -91,3 +92,66 @@ export function trackUsageFromMessageEndEvent(taskId: string, event: unknown): v
91
92
  cost: usage.cost,
92
93
  });
93
94
  }
95
+
96
+ /**
97
+ * R6-9 (DR-03 round-6): best-effort usage extraction for the fault/watchdog
98
+ * escape returns. The success return reports usageFromSessionStats(session as SessionWithStats | undefined),
99
+ * but the fault/watchdog catch paths previously carried NO usage — whenever
100
+ * embeddedWorkers routed work to the embedded tier and the task faulted, the
101
+ * burned turns vanished from the budget (under-count). The session may be
102
+ * disposed or partially unusable after the fault, so stats collection is
103
+ * guarded — it can never turn a fault into a crash. Returns undefined when no
104
+ * usable stats surface exists.
105
+ */
106
+ export function faultPathUsage(session: SessionWithStats | undefined): UsageState | undefined {
107
+ try {
108
+ return usageFromSessionStats(session as SessionWithStats | undefined);
109
+ } catch (error) {
110
+ logInternalError("live-session.fault-usage-stats", error, "session stats unavailable on fault path");
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Type-safe extractor for tool name from streaming events.
117
+ * Handles various event structures: { tool: { name } }, { toolName }, { name }.
118
+ */
119
+ export function extractToolName(obj: Record<string, unknown> | undefined): string | undefined {
120
+ if (!obj) return undefined;
121
+ const tool = asRecord(obj.tool);
122
+ if (isString(tool?.name)) return tool.name;
123
+ if (isString(obj.toolName)) return obj.toolName;
124
+ if (isString(obj.name)) return obj.name;
125
+ return undefined;
126
+ }
127
+
128
+ export function textFromContent(content: unknown): string[] {
129
+ if (typeof content === "string") return [content];
130
+ if (!Array.isArray(content)) return [];
131
+ return content.flatMap((part) => {
132
+ const obj = asRecord(part);
133
+ if (!obj) return [];
134
+ if (obj.type === "text" && typeof obj.text === "string") return [obj.text];
135
+ if (typeof obj.content === "string") return [obj.content];
136
+ return [];
137
+ });
138
+ }
139
+
140
+ export function eventText(event: unknown): string[] {
141
+ const obj = asRecord(event);
142
+ if (!obj) return [];
143
+ const text: string[] = [];
144
+ if (typeof obj.text === "string") text.push(obj.text);
145
+ text.push(...textFromContent(obj.content));
146
+ const message = asRecord(obj.message);
147
+ if (message) text.push(...textFromContent(message.content));
148
+ return text.filter((entry) => entry.trim());
149
+ }
150
+
151
+ export function finalAssistantText(event: unknown): string[] {
152
+ const obj = asRecord(event);
153
+ if (obj?.type !== "message_end") return [];
154
+ const message = asRecord(obj.message);
155
+ if (message?.role !== "assistant") return [];
156
+ return textFromContent(message.content);
157
+ }
@@ -206,3 +206,13 @@ export function stripMcpExtensions(base: LoadedExtensionsShape): LoadedExtension
206
206
  ],
207
207
  };
208
208
  }
209
+
210
+ /**
211
+ * R1-M2 part 2 (Wave-3): pure fail-closed decision — when G2 MCP enforcement
212
+ * could not be armed (SDK loader missing / degraded), a NON-PERMITTED role
213
+ * must ABORT the live-session spawn instead of warn+proceed (fail-open);
214
+ * permitted roles keep the warn+proceed behavior (their access is intended).
215
+ */
216
+ export function shouldFailClosedMcpEnforcement(mcpPermitted: boolean, degradationReason: string | undefined): boolean {
217
+ return degradationReason !== undefined && !mcpPermitted;
218
+ }
@@ -444,13 +444,81 @@ const NON_RETRYABLE_MODEL_FAILURE_PATTERNS = [
444
444
  /credit/i,
445
445
  ];
446
446
 
447
+ // R2-M-B2 (Wave-3): provider SAFETY/REFUSAL stop reasons must NOT be retried
448
+ // down the fallback chain ("safety-refusal laundering" — re-dispatching the
449
+ // same refused prompt to other models). The STRUCTURED stop reason is the
450
+ // authoritative signal; the legacy `/safety/i` substring in
451
+ // RETRYABLE_MODEL_FAILURE_PATTERNS stays as a FALLBACK only for errors that
452
+ // carry no structured marker (legacy serialization).
453
+ export const SAFETY_REFUSAL_STOP_REASONS: ReadonlySet<string> = new Set([
454
+ "refusal",
455
+ "refused",
456
+ "safety",
457
+ "safety_block",
458
+ "safety_block_consumed",
459
+ "content_policy",
460
+ "content_policy_violation",
461
+ "content_filter",
462
+ ]);
463
+
464
+ /** Normalize + match a stop reason (or stopReason/stop_reason value) against the refusal/safety set. */
465
+ export function isSafetyRefusalStopReason(reason: unknown): boolean {
466
+ if (typeof reason !== "string") return false;
467
+ return SAFETY_REFUSAL_STOP_REASONS.has(reason.trim().toLowerCase());
468
+ }
469
+
470
+ const SERIALIZED_STOP_REASON_MARKER = /(?:stop_reason|stopReason|stop reason)["':=\s]+["']?([a-z_\- ]+)/i;
471
+
472
+ /**
473
+ * Structured safety-refusal detection on an ERROR OBJECT (preferred) or a
474
+ * serialized error string: true when the error carries a stop_reason in the
475
+ * refusal/safety family. Object shape: `{ stopReason }`, `{ stop_reason }`,
476
+ * nested under `.error`/`.cause`, or the same marker serialized into a string
477
+ * (JSON.stringify'd errors, stderr tails).
478
+ */
479
+ export function hasStructuredSafetyRefusal(errorLike: unknown): boolean {
480
+ if (!errorLike) return false;
481
+ if (typeof errorLike === "string") {
482
+ const match = SERIALIZED_STOP_REASON_MARKER.exec(errorLike);
483
+ return match ? isSafetyRefusalStopReason(match[1]) : false;
484
+ }
485
+ if (typeof errorLike !== "object") return false;
486
+ const obj = errorLike as Record<string, unknown>;
487
+ if (isSafetyRefusalStopReason(obj.stopReason)) return true;
488
+ if (isSafetyRefusalStopReason(obj.stop_reason)) return true;
489
+ if (obj.error !== undefined && obj.error !== obj && hasStructuredSafetyRefusal(obj.error)) return true;
490
+ if (obj.cause !== undefined && obj.cause !== obj && hasStructuredSafetyRefusal(obj.cause)) return true;
491
+ // Error instances: message may carry the serialized marker.
492
+ if (typeof obj.message === "string") {
493
+ const match = SERIALIZED_STOP_REASON_MARKER.exec(obj.message);
494
+ if (match && isSafetyRefusalStopReason(match[1])) return true;
495
+ }
496
+ return false;
497
+ }
498
+
447
499
  export function isRetryableModelFailure(error: string | undefined): boolean {
448
500
  if (!error) return false;
501
+ // R2-M-B2 (Wave-3): structured safety/refusal stop reasons are checked
502
+ // FIRST — non-retryable even when the message text otherwise matches
503
+ // retryable-looking patterns ("overloaded", "transient", …). The plain
504
+ // `/safety/i` substring below remains as the LEGACY fallback for errors
505
+ // without a structured marker.
506
+ if (hasStructuredSafetyRefusal(error)) return false;
449
507
  // Auth / billing / invalid-key failures will never succeed on retry.
450
508
  if (NON_RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error))) return false;
451
509
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
452
510
  }
453
511
 
512
+ /**
513
+ * R2-M-B2 companion for call sites that still hold the structured CAUSE
514
+ * object: consult it FIRST (authoritative), then fall back to the string
515
+ * classification of `message`.
516
+ */
517
+ export function isRetryableModelFailureFrom(cause: unknown, message?: string): boolean {
518
+ if (hasStructuredSafetyRefusal(cause)) return false;
519
+ return isRetryableModelFailure(message ?? (cause instanceof Error ? cause.message : undefined));
520
+ }
521
+
454
522
  export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
455
523
  const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
456
524
  return nextModel
@@ -40,6 +40,21 @@ export function resolveEmbeddedFaultThreshold(policy: CrewRuntimeConfig["embedde
40
40
  * (the same depth>0 rule is applied to the embedded upgrade itself).
41
41
  * - Otherwise, use `isolationPolicy.defaultRuntime` when configured, then fall back to globalKind.
42
42
  */
43
+ /**
44
+ * wave-2b (mock double-gate): PI_CREW_MOCK_LIVE_SESSION is a test seam that
45
+ * flips production runtime selection (policy + resolver) and fabricates a
46
+ * mock session — single-gated, an env var inherited from a hostile parent
47
+ * would silently downgrade a worker to a mock. Mirror the existing
48
+ * double-gated seams (PI_CREW_TEST_ASYNC_INLINE + PI_CREW_ALLOW_MOCK in
49
+ * async-runner.ts; PI_TEAMS_MOCK_CHILD_PI + PI_CREW_ALLOW_MOCK in
50
+ * mock-fixtures.ts): the mock only engages when PI_CREW_ALLOW_MOCK is ALSO
51
+ * set, and PI_CREW_ALLOW_MOCK is filtered from child envs.
52
+ */
53
+ export function mockLiveSessionEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
54
+ if (env.PI_CREW_MOCK_LIVE_SESSION !== "success") return false;
55
+ return env.PI_CREW_ALLOW_MOCK === "1" || env.PI_CREW_ALLOW_MOCK === "true";
56
+ }
57
+
43
58
  export function resolveTaskRuntimeKind(
44
59
  globalKind: CrewRuntimeKind,
45
60
  role: string,
@@ -51,22 +66,21 @@ export function resolveTaskRuntimeKind(
51
66
  // Safety: when already inside a pi-crew worker (depth > 0), never nest live-session.
52
67
  // Live-session creates in-process Pi agent sessions, which would recursively
53
68
  // try to use pi-crew, leading to "Cannot read properties of undefined" errors.
54
- // Exception: when PI_CREW_MOCK_LIVE_SESSION is set, we're in a test harness
55
- // that mocks the live-session path — forcing child-process would spawn a real
56
- // pi process and hang the test.
57
- if (globalKind === "live-session" && currentCrewDepth(env) > 0 && env.PI_CREW_MOCK_LIVE_SESSION !== "success") return "child-process";
69
+ // Exception: the double-gated mock seam (mockLiveSessionEnabled) — forcing
70
+ // child-process would spawn a real pi process and hang the test harness.
71
+ if (globalKind === "live-session" && currentCrewDepth(env) > 0 && !mockLiveSessionEnabled(env)) return "child-process";
58
72
  const isolatedRoles = isolationPolicy?.isolatedRoles ?? [];
59
73
  if (isolatedRoles.includes(role)) return "child-process";
60
74
  // U11 embedded-tier upgrade (DEFAULT-OFF). Guards mirror the depth guard
61
75
  // above: an in-process embedded session has the SAME nesting hazard, so
62
76
  // depth>0 keeps the task on child-process regardless of which kind the
63
77
  // caller resolved — the guard is EXTENDED to the embedded path, never
64
- // weakened (PI_CREW_MOCK_LIVE_SESSION keeps its test-harness exception).
78
+ // weakened (the double-gated mock seam keeps its test-harness exception).
65
79
  if (
66
80
  embedded?.policy?.enabled === true &&
67
81
  (embedded.policy.roles ?? []).includes(role) &&
68
82
  (embedded.faultCount ?? 0) < resolveEmbeddedFaultThreshold(embedded.policy) &&
69
- (currentCrewDepth(env) <= 0 || env.PI_CREW_MOCK_LIVE_SESSION === "success")
83
+ (currentCrewDepth(env) <= 0 || mockLiveSessionEnabled(env))
70
84
  ) {
71
85
  return "live-session";
72
86
  }
@@ -1,6 +1,7 @@
1
1
  import type { PiTeamsConfig } from "../../config/config.ts";
2
2
  import type { RuntimeResolutionState } from "../../state/types.ts";
3
3
  import type { CrewRuntimeKind } from "../crew-agent-runtime.ts";
4
+ import { mockLiveSessionEnabled } from "./runtime-policy.ts";
4
5
 
5
6
  export type CrewRuntimeMode = "auto" | "scaffold" | "child-process" | "live-session";
6
7
 
@@ -35,7 +36,8 @@ export async function isLiveSessionRuntimeAvailable(
35
36
  timeoutMs = 1500,
36
37
  env: NodeJS.ProcessEnv = process.env,
37
38
  ): Promise<{ available: boolean; reason?: string }> {
38
- if (env.PI_CREW_MOCK_LIVE_SESSION === "success") {
39
+ // wave-2b: double-gated mock seam — see mockLiveSessionEnabled.
40
+ if (mockLiveSessionEnabled(env)) {
39
41
  return {
40
42
  available: true,
41
43
  reason: "Mock live-session runtime is enabled.",
@@ -112,8 +114,8 @@ export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.Proc
112
114
  }
113
115
  // auto mode: use child-process unless preferLiveSession is explicitly enabled
114
116
  if (requestedMode === "auto") {
115
- // Check for mock env var first (for testing)
116
- if (env.PI_CREW_MOCK_LIVE_SESSION === "success") {
117
+ // Check for the double-gated mock seam first (for testing)
118
+ if (mockLiveSessionEnabled(env)) {
117
119
  const live = await isLiveSessionRuntimeAvailable(1500, env);
118
120
  if (live.available) return liveCaps(requestedMode);
119
121
  }