pi-crew 0.11.5 → 0.11.7

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 (68) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +1 -1
  3. package/dist/index.mjs +503 -493
  4. package/docs/dynamic-workflows.md +37 -0
  5. package/package.json +13 -9
  6. package/scripts/analyze-run.mjs +95 -11
  7. package/skills/model-routing-context/SKILL.md +4 -3
  8. package/skills/real-test-pi-crew/SKILL.md +9 -6
  9. package/src/config/config-validation.ts +28 -0
  10. package/src/config/env-vars.ts +29 -0
  11. package/src/config/types.ts +47 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/respond.ts +54 -0
  16. package/src/extension/team-tool/status.ts +10 -0
  17. package/src/extension/team-tool.ts +393 -3
  18. package/src/extension/webhook-notify.ts +23 -1
  19. package/src/observability/exporters/otlp-exporter.ts +93 -11
  20. package/src/prompt/prompt-runtime.ts +46 -8
  21. package/src/runtime/agent-control.ts +1 -15
  22. package/src/runtime/background-runner.ts +3 -0
  23. package/src/runtime/broker/crew-broker.ts +12 -103
  24. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  25. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  26. package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
  27. package/src/runtime/child-pi/child-pi.ts +93 -4
  28. package/src/runtime/child-pi/session-recovery.ts +334 -0
  29. package/src/runtime/dispatch-batch.ts +3 -1
  30. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  32. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  33. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  34. package/src/runtime/mcp-proxy.ts +110 -7
  35. package/src/runtime/model/model-scope.ts +141 -21
  36. package/src/runtime/model/pi-args.ts +115 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/peer-dep.ts +24 -0
  39. package/src/runtime/rpc/README.md +100 -0
  40. package/src/runtime/rpc/frame-client.ts +328 -0
  41. package/src/runtime/rpc/rpc-worker.ts +151 -0
  42. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  43. package/src/runtime/run-worker.ts +14 -0
  44. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  45. package/src/runtime/stale-reconciler.ts +47 -0
  46. package/src/runtime/surface/surface-spawn.ts +8 -1
  47. package/src/runtime/task-output-context.ts +191 -32
  48. package/src/runtime/task-packet.ts +3 -0
  49. package/src/runtime/task-runner/child-executor.ts +170 -6
  50. package/src/runtime/task-runner/output-splitter.ts +48 -5
  51. package/src/runtime/task-runner/post-execution.ts +5 -5
  52. package/src/runtime/task-runner/pre-execution.ts +32 -4
  53. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  54. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  55. package/src/schema/config-schema.ts +25 -0
  56. package/src/schema/team-tool-schema.ts +35 -0
  57. package/src/state/contracts.ts +16 -0
  58. package/src/state/crew-init.ts +114 -0
  59. package/src/state/stores/active-run-registry.ts +13 -6
  60. package/src/state/types.ts +0 -8
  61. package/src/ui/goal-flag.ts +44 -0
  62. package/src/ui/live-run-sidebar.ts +5 -1
  63. package/src/ui/pi-ui-compat.ts +37 -11
  64. package/src/ui/run-dashboard.ts +2 -1
  65. package/src/ui/widget/index.ts +0 -1
  66. package/src/ui/widget/widget-model.ts +6 -2
  67. package/src/utils/ip-literal.ts +145 -0
  68. package/src/workflows/workflow-config.ts +6 -0
@@ -16,6 +16,7 @@ import { pollWorkerInbox } from "./inbox-poll.ts";
16
16
  import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
17
17
  import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
18
18
  import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
19
+ import { createWorkerEventsChannel } from "./worker-events-channel.ts";
19
20
 
20
21
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
21
22
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
@@ -621,10 +622,19 @@ function findAskResponse(manifest: TeamRunManifest, questionId: string): Mailbox
621
622
  * report — best-effort wait.resolve on EVERY terminal path (answered /
622
623
  * timed-out / aborted). A rejected resolve leaves the task parked; the
623
624
  * scheduler's TTL leak-guard (stale-reconciler) is the backstop, so the
624
- * failure is logged, never thrown. */
625
- async function resolvePark(client: AskBrokerClientSurface, taskId: string, questionId: string): Promise<void> {
625
+ * failure is logged, never thrown.
626
+ * G5 (deep-review 2026-10-01): the terminal outcome rides the resolve frame
627
+ * so the broker can emit `task.respond_delivered` ONLY when the worker
628
+ * actually picked up the leader's mailbox response ("answered") — a
629
+ * timed-out/aborted park was NOT a delivery and must not masquerade as one. */
630
+ async function resolvePark(
631
+ client: AskBrokerClientSurface,
632
+ taskId: string,
633
+ questionId: string,
634
+ outcome: "answered" | "timed-out" | "aborted",
635
+ ): Promise<void> {
626
636
  try {
627
- const res = await client.request("wait.resolve", { to: taskId, questionId });
637
+ const res = await client.request("wait.resolve", { to: taskId, questionId, outcome });
628
638
  if (!res.ok) {
629
639
  logInternalError(
630
640
  "prompt-runtime.ask-wait-resolve",
@@ -644,10 +654,11 @@ async function resolvePark(client: AskBrokerClientSurface, taskId: string, quest
644
654
  }
645
655
 
646
656
  /** ADR item 10: every ask-timeout outcome appends `ask.timedout` to the run's
647
- * events.jsonl. The worker cannot rely on PI_CREW_EVENTS_PATH (scratchpad-
648
- * gated), but the state store pins eventsPath === <stateRoot>/events.jsonl —
649
- * the same invariant child-pi-spawn relies on to derive PI_CREW_STATE_ROOT.
650
- * Fire-and-forget: never blocks the tool result. */
657
+ * events.jsonl. WP-9 made PI_CREW_EVENTS_PATH unconditional (this comment
658
+ * used to say it was scratchpad-gated — stale); this path predates that and
659
+ * keeps deriving the file from the state store invariant eventsPath ===
660
+ * <stateRoot>/events.jsonl — the same invariant child-pi-spawn relies on to
661
+ * derive PI_CREW_STATE_ROOT. Fire-and-forget: never blocks the tool result. */
651
662
  function emitAskTimedOutEvent(stateRoot: string, runId: string, taskId: string, questionId: string): void {
652
663
  appendEventFireAndForget(path.join(stateRoot, "events.jsonl"), {
653
664
  type: "ask.timedout",
@@ -774,7 +785,7 @@ export function createAskTool(deps: AskToolDeps = {}): AskToolDefinition {
774
785
  }
775
786
  const waitedMs = now() - startedAt;
776
787
  // Terminal report (ADR item 8): best-effort un-park on EVERY path.
777
- await resolvePark(client, taskId, questionId);
788
+ await resolvePark(client, taskId, questionId, terminal);
778
789
  if (terminal === "answered" && answer) {
779
790
  return {
780
791
  content: [{ type: "text" as const, text: renderAskAnswer(questionId, answer.body) }],
@@ -1021,6 +1032,33 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1021
1032
  void brokerHandle.close().catch(() => undefined);
1022
1033
  });
1023
1034
 
1035
+ // ── W5 (P2-2): observe worker auto-compaction ──────────────────────────
1036
+ // Auto-compaction inside a worker is otherwise silent from the
1037
+ // orchestrator's view (perf reports only see token jumps). The SDK fires
1038
+ // `session_before_compact` right before the host pi compacts the session
1039
+ // context; this listener OBSERVES ONLY — it returns undefined so the host
1040
+ // proceeds with its own preparation untouched (host analog:
1041
+ // compaction-guard.ts `pi.on("session_before_compact", async () => { return; })`).
1042
+ // NAME DISCIPLINE: this is the SDK's SESSION compaction (host context
1043
+ // summarization) — NOT pi-crew's internal `prepareCompaction` event-log
1044
+ // rotation (event-log-rotation.ts); same word, different mechanism.
1045
+ // Channel discipline (WP-9): createWorkerEventsChannel is registered
1046
+ // unconditionally and self-no-ops without PI_CREW_EVENTS_PATH/
1047
+ // PI_CREW_BROKER_RUN_ID, so a main user session pays nothing. Payload is
1048
+ // scalar-only: branchEntries (large) and signal (AbortSignal) are never
1049
+ // serialized; preparation fields are read defensively (SDK shape evolves).
1050
+ const compactEventsChannel = createWorkerEventsChannel();
1051
+ pi.on("session_before_compact", (event) => {
1052
+ const tokensBefore = (event.preparation as { tokensBefore?: unknown } | undefined)?.tokensBefore;
1053
+ compactEventsChannel.emit("worker.session_before_compact", {
1054
+ reason: event.reason,
1055
+ willRetry: event.willRetry,
1056
+ branchEntryCount: event.branchEntries?.length ?? 0,
1057
+ hasCustomInstructions: typeof event.customInstructions === "string",
1058
+ ...(typeof tokensBefore === "number" ? { tokensBefore } : {}),
1059
+ });
1060
+ });
1061
+
1024
1062
  // ── Task 5 (§15.2): worker inbox pickup ────────────────────────────────
1025
1063
  // A broker-eligible worker ALSO picks up sibling DMs / group broadcasts
1026
1064
  // (`kind:"message"` entries addressed to this task in the durable
@@ -1,6 +1,5 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type { PiTeamsConfig } from "../config/config.ts";
3
- import type { ControlReservation, TeamRunManifest } from "../state/types.ts";
2
+ import type { TeamRunManifest } from "../state/types.ts";
4
3
  import { appendTaskAttentionEvent } from "./attention-events.ts";
5
4
  import { upsertCrewAgent } from "./crew-agent-records.ts";
6
5
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
@@ -200,16 +199,3 @@ function resetConsecutiveToolFailures(manifest: TeamRunManifest, agent: CrewAgen
200
199
  };
201
200
  upsertCrewAgent(manifest, updated);
202
201
  }
203
-
204
- /**
205
- * Reserve a control channel for a task before spawning its worker.
206
- * This ensures cancel/steer requests can be queued immediately
207
- * while the worker is still starting up.
208
- */
209
- export function reserveControlChannel(taskId: string, runId: string): ControlReservation {
210
- return {
211
- reservedAt: new Date().toISOString(),
212
- controllerId: `ctrl:${taskId}:${randomUUID()}`,
213
- acceptsControlEvents: true,
214
- };
215
- }
@@ -544,6 +544,9 @@ export async function executeBackgroundRun(
544
544
  workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
545
545
  signal,
546
546
  tokenBudget: wf.maxTokenBudget,
547
+ // SDD-3 W-C G13: workflow-config override for the agent-call cap
548
+ // (unset → ctx default; a run is always bounded).
549
+ maxAgentCalls: wf.maxAgentCalls,
547
550
  });
548
551
  await saveRunManifestAsync(dwfResult.manifest);
549
552
  earlyResult = dwfResult;
@@ -54,12 +54,12 @@ import {
54
54
  isRequestObject,
55
55
  parseMsgSendParams,
56
56
  parseWaitRequestParams,
57
- parseWaitResolveParams,
58
57
  safeStringify,
59
58
  WAIT_REQUEST_TIMEOUT_SEC_DEFAULT,
60
59
  WAIT_REQUEST_TIMEOUT_SEC_MAX,
61
60
  } from "./protocol/request-parsers.ts";
62
61
  import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
62
+ import { handleWaitResolve } from "./protocol/wait-resolve.ts";
63
63
  import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
64
64
  import { WaitStatusCache } from "./wait-status-cache.ts";
65
65
 
@@ -1883,110 +1883,19 @@ export class CrewBroker {
1883
1883
  });
1884
1884
  }
1885
1885
 
1886
- /** WP-2/R2: terminal report of the parked `ask` tool — flips the task
1887
- * waiting→running and clears the park coordination state. Scoped to
1888
- * auth + state transition + ask.answered event ONLY (ADR item 6/8):
1889
- * answer DELIVERY is the mailbox respond path (step 6), not this
1890
- * method. A questionId mismatch (or a task that is not parked) is
1891
- * rejected WITHOUT clearing anything — fail-closed. */
1886
+ /** WP-2/R2/R3: wait.resolve handler lives in ./protocol/wait-resolve.ts
1887
+ * (moved out for the wc-gate M4 §5 — pure move, no behavior change). */
1892
1888
  private async handleWaitResolve(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1893
- if (!conn.runId || !conn.taskId) {
1894
- this.sendError(conn, id, "auth", "not authed");
1895
- return;
1896
- }
1897
- const authErr = waitAuthError(conn);
1898
- if (authErr) {
1899
- this.sendError(conn, id, authErr.code, authErr.message);
1900
- return;
1901
- }
1902
- const parsed = parseWaitResolveParams(params);
1903
- if (!parsed) {
1904
- this.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
1905
- return;
1906
- }
1907
- // Server-side identity enforcement (same rule as wait.request).
1908
- if (parsed.to !== conn.taskId) {
1909
- this.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
1910
- return;
1911
- }
1912
- const cwd = this.options.cwd;
1913
- if (!cwd) {
1914
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1915
- return;
1916
- }
1917
- let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1918
- try {
1919
- const l = loadRunManifestById(cwd, conn.runId);
1920
- if (!l) {
1921
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1922
- return;
1923
- }
1924
- loaded = l;
1925
- } catch (err) {
1926
- this.sendError(conn, id, "no-manifest", (err as Error).message);
1927
- return;
1928
- }
1929
- if (this.options.waitMethodsEnabled !== true) {
1930
- recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
1931
- this.sendError(
1932
- conn,
1933
- id,
1934
- "policy-disabled",
1935
- "wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
1936
- );
1937
- return;
1938
- }
1939
- const runId = conn.runId;
1940
- const taskId = conn.taskId;
1941
- const outcome = withRunLockSync(loaded.manifest, () => {
1942
- const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1943
- if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1944
- const task = fresh.tasks.find((t) => t.id === taskId);
1945
- if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
1946
- if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
1947
- return {
1948
- code: "bad-params" as const,
1949
- message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
1950
- };
1951
- }
1952
- const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
1953
- // waitState is a single run-level slot: clear it ONLY when it points
1954
- // at this exact question — never clobber another task's newer park.
1955
- const waitState = fresh.manifest.waitState;
1956
- const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
1957
- const updatedManifest = {
1958
- ...fresh.manifest,
1959
- ...(clearWaitState ? { waitState: undefined } : {}),
1960
- updatedAt: new Date().toISOString(),
1961
- };
1962
- saveRunTasks(updatedManifest, updatedTasks);
1963
- saveRunManifest(updatedManifest);
1964
- return { code: "ok" as const, message: "" };
1965
- });
1966
- if (outcome.code !== "ok") {
1967
- this.sendError(conn, id, outcome.code, outcome.message);
1968
- return;
1969
- }
1970
- const eventsPath = loaded.manifest.eventsPath;
1971
- void appendEventAsync(eventsPath, {
1972
- type: "ask.answered",
1973
- runId,
1974
- taskId,
1975
- message: `Question ${parsed.questionId} answered; task resumed.`,
1976
- data: { questionId: parsed.questionId },
1977
- }).catch((err) =>
1978
- logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1979
- );
1980
- void appendEventAsync(eventsPath, {
1981
- type: "task.resumed",
1982
- runId,
1983
- taskId,
1984
- message: `Task resumed after ask answer (question ${parsed.questionId}).`,
1985
- data: { questionId: parsed.questionId },
1986
- }).catch((err) =>
1987
- logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1889
+ await handleWaitResolve(
1890
+ conn,
1891
+ id,
1892
+ params,
1893
+ {
1894
+ sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
1895
+ sendResult: (c, i, r) => this.sendResult(c, i, r),
1896
+ },
1897
+ { cwd: this.options.cwd, waitMethodsEnabled: this.options.waitMethodsEnabled },
1988
1898
  );
1989
- this.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
1990
1899
  }
1991
1900
  }
1992
1901
 
@@ -172,6 +172,13 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
172
172
  export interface WaitResolveParams {
173
173
  to: string;
174
174
  questionId: string;
175
+ /** G5 (deep-review 2026-10-01): the parked worker's terminal outcome —
176
+ * "answered" = it PICKED UP the leader's mailbox response for this
177
+ * questionId (the only outcome that triggers the broker's
178
+ * task.respond_delivered ack event); "timed-out"/"aborted" resolve the
179
+ * park WITHOUT a delivery claim. Optional + fail-closed: legacy workers
180
+ * that omit it never claim a delivery (no event is emitted). */
181
+ outcome?: "answered" | "timed-out" | "aborted";
175
182
  }
176
183
 
177
184
  export function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
@@ -179,5 +186,6 @@ export function parseWaitResolveParams(value: unknown): WaitResolveParams | unde
179
186
  const v = value as Record<string, unknown>;
180
187
  if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
181
188
  if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
182
- return { to: v.to, questionId: v.questionId };
189
+ if (v.outcome !== undefined && v.outcome !== "answered" && v.outcome !== "timed-out" && v.outcome !== "aborted") return undefined;
190
+ return { to: v.to, questionId: v.questionId, ...(v.outcome !== undefined ? { outcome: v.outcome } : {}) };
183
191
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * wait-resolve.ts — WP-2/R2 `wait.resolve` broker handler.
3
+ *
4
+ * Moved from crew-broker.ts (wc-gate M4 §5, GH #61 release audit 2026-10-02)
5
+ * — pure move, no behavior change. Terminal report of the parked `ask` tool:
6
+ * flips the task waiting→running and clears the park coordination state.
7
+ * Scoped to auth + state transition + ask.answered/task.resumed events +
8
+ * the G5 respond_delivered ack ONLY (ADR item 6/8): answer DELIVERY is the
9
+ * mailbox respond path (step 6), not this handler. A questionId mismatch
10
+ * (or a task that is not parked) is rejected WITHOUT clearing anything —
11
+ * fail-closed.
12
+ *
13
+ * Top-level function so the class method is a 1-line delegation
14
+ * (same pattern as msg-inbox.ts).
15
+ */
16
+
17
+ import { withRunLockSync } from "../../../state/coordination/locks.ts";
18
+ import { appendEventAsync } from "../../../state/event-log/event-log.ts";
19
+ import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../../state/stores/state-store.ts";
20
+ import { logInternalError } from "../../../utils/internal-error.ts";
21
+ import type { ServerConnection } from "./connection-state.ts";
22
+ import { parseWaitResolveParams } from "./request-parsers.ts";
23
+ import { recordWaitPolicyRejection } from "./wait-auth.ts";
24
+
25
+ export interface WaitResolveHelpers {
26
+ sendError(conn: ServerConnection, id: string, code: string, message: string): void;
27
+ sendResult(conn: ServerConnection, id: string, result: unknown): void;
28
+ }
29
+
30
+ export interface WaitResolveOptions {
31
+ /** Broker cwd (run state root). Undefined → no-manifest error. */
32
+ cwd?: string;
33
+ /** Fail-closed gate: wait.* methods disabled unless explicitly true. */
34
+ waitMethodsEnabled?: boolean;
35
+ }
36
+
37
+ export async function handleWaitResolve(
38
+ conn: ServerConnection,
39
+ id: string,
40
+ params: unknown,
41
+ helpers: WaitResolveHelpers,
42
+ options: WaitResolveOptions,
43
+ ): Promise<void> {
44
+ if (!conn.runId || !conn.taskId) {
45
+ helpers.sendError(conn, id, "auth", "not authed");
46
+ return;
47
+ }
48
+ const parsed = parseWaitResolveParams(params);
49
+ if (!parsed) {
50
+ helpers.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
51
+ return;
52
+ }
53
+ // Server-side identity enforcement (same rule as wait.request).
54
+ if (parsed.to !== conn.taskId) {
55
+ helpers.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
56
+ return;
57
+ }
58
+ if (!options.cwd) {
59
+ helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
60
+ return;
61
+ }
62
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
63
+ try {
64
+ const l = loadRunManifestById(options.cwd, conn.runId);
65
+ if (!l) {
66
+ helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
67
+ return;
68
+ }
69
+ loaded = l;
70
+ } catch (err) {
71
+ helpers.sendError(conn, id, "no-manifest", (err as Error).message);
72
+ return;
73
+ }
74
+ if (options.waitMethodsEnabled !== true) {
75
+ recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
76
+ helpers.sendError(
77
+ conn,
78
+ id,
79
+ "policy-disabled",
80
+ "wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
81
+ );
82
+ return;
83
+ }
84
+ const runId = conn.runId;
85
+ const taskId = conn.taskId;
86
+ const outcome = withRunLockSync(loaded.manifest, () => {
87
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
88
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
89
+ const task = fresh.tasks.find((t) => t.id === taskId);
90
+ if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
91
+ if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
92
+ return {
93
+ code: "bad-params" as const,
94
+ message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
95
+ };
96
+ }
97
+ const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
98
+ // G5 (deep-review 2026-10-01): capture the liveness pid (heartbeat-first,
99
+ // checkpoint-fallback — the same source stale-reconciler uses) before
100
+ // the flip, so respond_delivered records the same worker-pid evidence
101
+ // its respond_missed twin does root-side.
102
+ const workerPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
103
+ // waitState is a single run-level slot: clear it ONLY when it points
104
+ // at this exact question — never clobber another task's newer park.
105
+ const waitState = fresh.manifest.waitState;
106
+ const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
107
+ const updatedManifest = {
108
+ ...fresh.manifest,
109
+ ...(clearWaitState ? { waitState: undefined } : {}),
110
+ updatedAt: new Date().toISOString(),
111
+ };
112
+ saveRunTasks(updatedManifest, updatedTasks);
113
+ saveRunManifest(updatedManifest);
114
+ return { code: "ok" as const, message: "", workerPid };
115
+ });
116
+ if (outcome.code !== "ok") {
117
+ helpers.sendError(conn, id, outcome.code, outcome.message);
118
+ return;
119
+ }
120
+ const eventsPath = loaded.manifest.eventsPath;
121
+ void appendEventAsync(eventsPath, {
122
+ type: "ask.answered",
123
+ runId,
124
+ taskId,
125
+ message: `Question ${parsed.questionId} answered; task resumed.`,
126
+ data: { questionId: parsed.questionId },
127
+ }).catch((err) =>
128
+ logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
129
+ );
130
+ void appendEventAsync(eventsPath, {
131
+ type: "task.resumed",
132
+ runId,
133
+ taskId,
134
+ message: `Task resumed after ask answer (question ${parsed.questionId}).`,
135
+ data: { questionId: parsed.questionId },
136
+ }).catch((err) =>
137
+ logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
138
+ );
139
+ // G5 (deep-review 2026-10-01): respond-delivery ack. wait.resolve fires on
140
+ // EVERY terminal path (answered/timed-out/aborted) — only the worker-claimed
141
+ // outcome "answered" is a DELIVERY: the parked ask tool found the
142
+ // questionId-tagged mailbox response and is resolving the park WITH the
143
+ // answer in hand (its findAskResponse exact-equality match). A
144
+ // timed-out/aborted resolve — or a legacy worker that sends no outcome —
145
+ // never claims a delivery: fail-closed, no event. This ack is the leader's
146
+ // only confirmation that a mailbox respond reached a live worker instead
147
+ // of falling into the void; the root-side respond_missed twin covers the
148
+ // dead-worker branch at write time (extension/team-tool/respond.ts).
149
+ if (parsed.outcome === "answered") {
150
+ void appendEventAsync(eventsPath, {
151
+ type: "task.respond_delivered",
152
+ runId,
153
+ taskId,
154
+ message: `Leader respond delivered: worker acknowledged the mailbox response for question ${parsed.questionId}.`,
155
+ data: {
156
+ questionId: parsed.questionId,
157
+ ...(outcome.workerPid !== undefined ? { workerPid: outcome.workerPid } : {}),
158
+ },
159
+ }).catch((err) =>
160
+ logInternalError(
161
+ "crew-broker.wait.respond-delivered-event",
162
+ err instanceof Error ? err : new Error(String(err)),
163
+ `runId=${runId}`,
164
+ ),
165
+ );
166
+ }
167
+ helpers.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
168
+ }
@@ -270,10 +270,17 @@ export function prepareSpawnContext(
270
270
  agent: input.agent,
271
271
  model: input.model,
272
272
  sessionEnabled: true,
273
+ // W2 (P1-1): deterministic session identity so crash-path tail-recovery
274
+ // can find the worker's session JSONL (see session-recovery.ts). Falls
275
+ // back to child-pi.ts deriveSessionPaths when unset (idempotent both ways).
276
+ sessionId: input.sessionId,
277
+ sessionDir: input.sessionDir,
273
278
  maxDepth: input.maxDepth,
274
279
  skillPaths: input.skillPaths,
275
280
  role: input.role,
276
281
  thinkingOverride: input.thinkingOverride,
282
+ systemPromptAppend: input.systemPromptAppend,
283
+ hermeticWorkers: input.hermeticWorkers,
277
284
  // ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
278
285
  // (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
279
286
  // child env gets parentDepth+1 = the true grandchild depth.
@@ -357,10 +364,11 @@ export function prepareSpawnContext(
357
364
  // F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
358
365
  // point it at a temp dir; the worker reads it then writeArtifact()
359
366
  // (redact+atomic) is the ONLY writer into artifactsRoot.
360
- // R3-1: built.tempDir is only created by buildPiWorkerArgs when the agent
361
- // has a systemPrompt OR the task exceeds TASK_ARG_LIMIT — guard against
362
- // undefined (resolveRealContainedPath(undefined) would TypeError and crash
363
- // spawn). createSafeTempDir auto-tracks the dir for cleanupAllTrackedTempDirs.
367
+ // R3-1: since G3 (spill-always) buildPiWorkerArgs creates a tempDir for
368
+ // EVERY spawn (task.md) — the `??` guard stays as pure defense against
369
+ // future call shapes (resolveRealContainedPath(undefined) would
370
+ // TypeError and crash spawn). createSafeTempDir auto-tracks the dir for
371
+ // cleanupAllTrackedTempDirs.
364
372
  const scratchTempDir = built.tempDir ?? createSafeTempDir(getPiTempBase(), "pi-crew-scratchpad-");
365
373
  built.env.PI_CREW_SCRATCHPAD_SNAPSHOT = resolveRealContainedPath(scratchTempDir, `${input.agentId}.snapshot.json`);
366
374
  // Phase 2 crash-resume (D1/D1b/D2): locate the latest snapshot artifact of a