pi-crew 0.11.4 → 0.11.6

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 (58) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -2
  3. package/dist/index.mjs +498 -492
  4. package/docs/actions-reference.md +22 -0
  5. package/docs/dynamic-workflows.md +37 -0
  6. package/index.ts +9 -5
  7. package/package.json +18 -14
  8. package/scripts/analyze-run.mjs +101 -14
  9. package/skills/model-routing-context/SKILL.md +4 -3
  10. package/skills/real-test-pi-crew/SKILL.md +10 -7
  11. package/src/config/env-vars.ts +6 -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/dispatch/index.ts +4 -3
  16. package/src/extension/team-tool/dispatch/status.ts +7 -3
  17. package/src/extension/team-tool/inspect.ts +90 -0
  18. package/src/extension/team-tool/respond.ts +54 -0
  19. package/src/extension/team-tool/status.ts +10 -0
  20. package/src/extension/team-tool.ts +393 -3
  21. package/src/extension/webhook-notify.ts +23 -1
  22. package/src/observability/exporters/otlp-exporter.ts +93 -11
  23. package/src/prompt/prompt-runtime.ts +13 -4
  24. package/src/runtime/agent-control.ts +1 -15
  25. package/src/runtime/background-runner.ts +3 -0
  26. package/src/runtime/broker/crew-broker.ts +12 -103
  27. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  28. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  29. package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
  30. package/src/runtime/dispatch-batch.ts +3 -1
  31. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  32. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  33. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  34. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  35. package/src/runtime/mcp-proxy.ts +110 -7
  36. package/src/runtime/model/pi-args.ts +14 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/recovery/crash-recovery.ts +6 -1
  39. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  40. package/src/runtime/stale-reconciler.ts +55 -0
  41. package/src/runtime/task-output-context.ts +11 -1
  42. package/src/runtime/task-runner/child-executor.ts +130 -4
  43. package/src/runtime/task-runner/output-splitter.ts +48 -5
  44. package/src/runtime/task-runner/post-execution.ts +2 -2
  45. package/src/runtime/task-runner/pre-execution.ts +37 -24
  46. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  47. package/src/schema/team-tool-schema.ts +27 -1
  48. package/src/state/contracts.ts +16 -0
  49. package/src/state/crew-init.ts +114 -0
  50. package/src/state/stores/active-run-registry.ts +13 -6
  51. package/src/state/types.ts +0 -8
  52. package/src/ui/goal-flag.ts +44 -0
  53. package/src/ui/live-run-sidebar.ts +5 -1
  54. package/src/ui/pi-ui-compat.ts +20 -3
  55. package/src/ui/run-dashboard.ts +2 -1
  56. package/src/ui/widget/widget-model.ts +6 -2
  57. package/src/utils/ip-literal.ts +145 -0
  58. package/src/workflows/workflow-config.ts +6 -0
@@ -32,6 +32,7 @@ import { writeArtifact } from "../../state/stores/artifact-store.ts";
32
32
  import type { TeamRunManifest } from "../../state/types.ts";
33
33
  import type { TeamConfig } from "../../teams/team-config.ts";
34
34
  import { logInternalError } from "../../utils/internal-error.ts";
35
+ import { safeAbort } from "../../utils/safe-abort.ts";
35
36
  import { cleanupAgentWorktreeAsync, prepareAgentWorktreeAsync } from "../../worktree/worktree-manager.ts";
36
37
  import type { DwfCheckpointState } from "../dwf-state-store.ts";
37
38
  import { parsePiJsonOutput } from "../output/pi-json-output.ts";
@@ -40,6 +41,7 @@ import { executeWithRetry } from "../recovery/retry-executor.ts";
40
41
  import { runWorker } from "../run-worker.ts";
41
42
  import { mapConcurrent } from "../scheduling/parallel-utils.ts";
42
43
  import { Semaphore } from "../scheduling/semaphore.ts";
44
+ import { estimateTokens } from "../task-runner/prompt-builder.ts";
43
45
  import { renderPlanTemplate } from "./plan-templates.ts";
44
46
 
45
47
  export interface AgentCallOpts {
@@ -102,6 +104,34 @@ export interface WorkflowBudget {
102
104
  remaining(): number;
103
105
  }
104
106
 
107
+ /** SDD-3 W-C G13: default cap on ctx.agent() invocations per DWF run.
108
+ *
109
+ * Rationale: the largest real workflows observed (distill pipelines, adaptive
110
+ * plans) use ~10-40 agent calls; 200 gives ≥5× headroom so a legitimate run
111
+ * never trips it. Cost-wise 200 calls × ~5k tokens/call bounds a no-budget run
112
+ * to roughly a million tokens — an explainable blast radius. For real spawns
113
+ * the 30-min script timeout usually binds first; this cap's job is the
114
+ * no-budget runaway class, which previously had NO bound on the number of
115
+ * spawned steps and died only via the blind timeout (upgrade plan §2 G13). */
116
+ export const DEFAULT_MAX_AGENT_CALLS = 200;
117
+
118
+ /** SDD-3 W-C G13: structured termination error thrown when the agent-call cap trips.
119
+ * Propagates out of ctx.agent() (and therefore out of the .dwf.ts script unless
120
+ * the script catches it) so the runner fails the run with a structured reason
121
+ * (dwf.failed) instead of waiting out the blind script timeout. */
122
+ export class DwfAgentCallCapError extends Error {
123
+ /** Effective cap that was tripped. */
124
+ readonly limit: number;
125
+ /** Completed agent() invocations when the cap tripped. */
126
+ readonly used: number;
127
+ constructor(limit: number, used: number) {
128
+ super(`dynamic workflow agent-call cap reached (${used}/${limit} calls; raise the run's maxAgentCalls to allow more)`);
129
+ this.name = "DwfAgentCallCapError";
130
+ this.limit = limit;
131
+ this.used = used;
132
+ }
133
+ }
134
+
105
135
  export interface WorkflowCtx {
106
136
  cwd: string;
107
137
  runId: string;
@@ -181,6 +211,10 @@ export interface MakeWorkflowCtxOptions {
181
211
  modelOverride?: string;
182
212
  /** round-14 P1-2: per-workflow token budget. null/undefined = unbounded. */
183
213
  tokenBudget?: number | null;
214
+ /** SDD-3 W-C G13: cap on ctx.agent() invocations (new spawns AND cached
215
+ * replays) per run. null/undefined = DEFAULT_MAX_AGENT_CALLS — a run is
216
+ * always bounded even with no tokenBudget configured. */
217
+ maxAgentCalls?: number | null;
184
218
  /** round-14 P1-5: typed workflow arguments (sourced from manifest.args). Defaults to {}. */
185
219
  args?: unknown;
186
220
  /** round-18 P2-3: checkpoint state to hydrate ctx with on resume. When provided,
@@ -252,6 +286,20 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
252
286
  const concurrency = Math.max(1, opts.concurrency ?? 4);
253
287
  const semaphore = new Semaphore(concurrency);
254
288
  let finalResult: { artifactPath: string; meta?: Record<string, unknown> } | undefined;
289
+ // SDD-3 W-C G13: effective agent-call cap. Always ≥ 1 — omitting every knob
290
+ // still leaves the run bounded (default DEFAULT_MAX_AGENT_CALLS). A cap error
291
+ // aborts capController below so in-flight children are killed and pipeline()
292
+ // rethrows the cap error instead of swallowing it into a null item.
293
+ const maxAgentCalls =
294
+ typeof opts.maxAgentCalls === "number" && Number.isFinite(opts.maxAgentCalls) && opts.maxAgentCalls >= 1
295
+ ? Math.floor(opts.maxAgentCalls)
296
+ : DEFAULT_MAX_AGENT_CALLS;
297
+ const capController = new AbortController();
298
+ // G13: combine the caller's signal with the cap controller so EITHER source
299
+ // (external abort OR cap trip) reaches runWorker and ctx.signal consumers.
300
+ // AbortSignal.any is available since Node 20.3 (project requires Node 22+);
301
+ // an already-aborted input yields an already-aborted combined signal.
302
+ const ctxSignal = AbortSignal.any([opts.signal, capController.signal]);
255
303
  // round-18 P2-3: agent invocation counter. Hydrated from a resumed checkpoint so a
256
304
  // resumed run keeps an accurate count; incremented in agent()'s finally block.
257
305
  let agentCount = opts.resumedState ? opts.resumedState.agentCount : 0;
@@ -290,7 +338,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
290
338
  cwd: manifest.cwd,
291
339
  runId: manifest.runId,
292
340
  goal: manifest.goal,
293
- signal: opts.signal,
341
+ signal: ctxSignal,
294
342
  semaphore,
295
343
  async agent(call: AgentCallOpts): Promise<AgentResult> {
296
344
  await semaphore.acquire();
@@ -299,10 +347,42 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
299
347
  // regardless of which return/throw path is taken.
300
348
  let worktreePath: string | undefined;
301
349
  let worktreeBranch: string | undefined;
302
- // BDG-2: declared before the try so the catch block can un-reserve on failure.
303
- const ESTIMATE = 4096;
350
+ // BDG-2 + G14 (SDD-3): content-based reserve estimate — replaces the flat
351
+ // ESTIMATE=4096. estimateTokens() (chars/4, the in-tree heuristic exported
352
+ // from runtime/task-runner/prompt-builder.ts — the same estimator
353
+ // pre-execution.ts uses) runs over the call's prompt + optional
354
+ // systemPrompt override, floored at 512 tokens for the fixed per-call
355
+ // overhead (schema/JSON directive, artifact headers) not present in the
356
+ // prompt string. Short calls stop over-reserving; very long prompts
357
+ // reserve proportionally so N concurrent calls cannot blow past the
358
+ // budget between reserve and the post-run adjust. Every BDG-2 site
359
+ // below (check/reserve/un-reserve/adjust/refund) uses THIS value, so
360
+ // refund always matches what was reserved.
361
+ const estimate = Math.max(512, estimateTokens((call.prompt?.length ?? 0) + (call.systemPrompt?.length ?? 0)));
304
362
  let reserved = false;
305
363
  try {
364
+ // SDD-3 W-C G13: agent-call cap — the structured bound for no-budget runs.
365
+ // Checked BEFORE the PERS-1 cache lookup on purpose: cached replays are
366
+ // free but must still count, so a spin loop over the same prompt cannot
367
+ // bypass the cap (the default 200 leaves ≥5× headroom over the largest
368
+ // real workflows, so resume replays never trip it legitimately). Counts
369
+ // completed invocations; up to concurrency-1 extra calls may already be
370
+ // in flight when it trips (they are killed by the cap abort below).
371
+ if (agentCount >= maxAgentCalls) {
372
+ // Durable record (dwf.log is the registered workflow-log event type).
373
+ appendEvent(manifest.eventsPath, {
374
+ type: "dwf.log",
375
+ runId: manifest.runId,
376
+ data: {
377
+ message: `agent-call cap reached: ${agentCount}/${maxAgentCalls} — terminating workflow (raise maxAgentCalls to allow more)`,
378
+ limit: maxAgentCalls,
379
+ used: agentCount,
380
+ },
381
+ });
382
+ // Kill in-flight children + make pipeline()/gatherReplies() see the abort.
383
+ safeAbort(capController, "dwf-agent-call-cap");
384
+ throw new DwfAgentCallCapError(maxAgentCalls, agentCount);
385
+ }
306
386
  // PERS-1: per-agent-call idempotency. Compute a deterministic call ID
307
387
  // from the call arguments and check if this call was already completed
308
388
  // (e.g. during a previous run before a crash). If so, return the cached
@@ -327,7 +407,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
327
407
  // BDG-2: reserve-then-adjust budget. Before spawning, estimate the cost and
328
408
  // reserve it by adding to wfState.spent. This prevents N concurrent calls from
329
409
  // all seeing the same remaining budget and overspending.
330
- if (budget.total !== null && budget.remaining() < ESTIMATE) {
410
+ if (budget.total !== null && budget.remaining() < estimate) {
331
411
  return {
332
412
  ok: false,
333
413
  text: "",
@@ -336,7 +416,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
336
416
  };
337
417
  }
338
418
  // Reserve the estimate before spawning.
339
- wfState.spent += ESTIMATE;
419
+ wfState.spent += estimate;
340
420
  reserved = true;
341
421
 
342
422
  const agentConfig = resolveAgentForRole(call.role, {
@@ -399,14 +479,14 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
399
479
  skillPaths: undefined, // skills resolved via agent config + team-role plumbing
400
480
  maxTurns: call.maxTurns,
401
481
  graceTurns: call.graceTurns,
402
- signal: opts.signal,
482
+ signal: ctxSignal,
403
483
  artifactsRoot: manifest.artifactsRoot,
404
484
  runId: manifest.runId,
405
485
  role: call.role ?? call.agent,
406
486
  });
407
487
  if (childResult.exitCode !== 0 || childResult.error) {
408
488
  // BDG-2: un-reserve the estimate on spawn failure.
409
- wfState.spent -= ESTIMATE;
489
+ wfState.spent -= estimate;
410
490
  reserved = false;
411
491
  return {
412
492
  ok: false,
@@ -418,8 +498,8 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
418
498
  const parsed = parsePiJsonOutput(childResult.stdout);
419
499
  // round-14 P1-2: accumulate this run's token usage into the workflow budget.
420
500
  // BDG-2: adjust the reserve — subtract the estimate, add the actual usage.
421
- // This correctly reduces spent when actualUsage < ESTIMATE.
422
- wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0) - ESTIMATE;
501
+ // This correctly reduces spent when actualUsage < estimate.
502
+ wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0) - estimate;
423
503
  reserved = false;
424
504
  let text = childResult.rawFinalText || parsed.finalText || "";
425
505
  // Round-11 test fix: parsePiJsonOutput only extracts text from pi event stream
@@ -468,7 +548,11 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
468
548
  };
469
549
  } catch (error) {
470
550
  // BDG-2: un-reserve the estimate on failure (only if still reserved).
471
- if (reserved) wfState.spent -= ESTIMATE;
551
+ if (reserved) wfState.spent -= estimate;
552
+ // SDD-3 W-C G13: cap errors must TERMINATE the script with the
553
+ // structured reason — degrading to ok:false would let scripts (and the
554
+ // review()/retry()/pipeline fallbacks) swallow the cap and keep looping.
555
+ if (error instanceof DwfAgentCallCapError) throw error;
472
556
  logInternalError("dynamic-workflow-context.agent", error, `runId=${manifest.runId}`);
473
557
  return {
474
558
  ok: false,
@@ -534,7 +618,9 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
534
618
  try {
535
619
  value = await stage(value as TResult, item, index);
536
620
  } catch (error) {
537
- if (opts.signal.aborted) throw error;
621
+ // G13: ctxSignal is aborted on cap trip — rethrow so the structured cap
622
+ // error terminates the workflow instead of degrading to a null item.
623
+ if (ctxSignal.aborted) throw error;
538
624
  ctx.log(`pipeline[${index}] failed: ${error instanceof Error ? error.message : String(error)}`);
539
625
  return null;
540
626
  }
@@ -669,7 +755,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
669
755
  const got = inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
670
756
  if (got.length >= messageIds.length) return got;
671
757
  await new Promise((r) => setTimeout(r, 500));
672
- if (opts.signal.aborted) return inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
758
+ if (ctxSignal.aborted) return inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
673
759
  }
674
760
  return readMailbox(manifest, "inbox").filter((m) => m.replyTo && messageIds.includes(m.replyTo));
675
761
  },
@@ -767,6 +853,12 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
767
853
  get: () => agentCount,
768
854
  enumerable: false,
769
855
  });
856
+ // SDD-3 W-C G13: effective run limits are read-only from the runner (mirrors
857
+ // __agentCount). Exposed so getWorkflowLimits() can report the effective cap.
858
+ Object.defineProperty(ctx, "__limits", {
859
+ get: () => ({ maxAgentCalls }),
860
+ enumerable: false,
861
+ });
770
862
  // PERS-1: completedAgentCalls is read-only from the runner; the agent() method
771
863
  // is the only writer. Exposed so getWorkflowCheckpoint() can include it.
772
864
  Object.defineProperty(ctx, "__completedAgentCalls", {
@@ -806,6 +898,13 @@ export function getWorkflowLogs(ctx: WorkflowCtx): string[] | undefined {
806
898
  return (ctx as unknown as { __logs?: string[] }).__logs;
807
899
  }
808
900
 
901
+ /** SDD-3 W-C G13: read the effective run limits (runner-only; not part of the public
902
+ * ctx surface). Mirrors getWorkflowFinalResult/getWorkflowPhaseState — exposed for
903
+ * tests and diagnostics so the default cap is observable without dispatching calls. */
904
+ export function getWorkflowLimits(ctx: WorkflowCtx): { maxAgentCalls: number } | undefined {
905
+ return (ctx as unknown as { __limits?: { maxAgentCalls: number } }).__limits;
906
+ }
907
+
809
908
  /** round-18 P2-3: snapshot the current DWF checkpoint state (runner-only; not part of the public
810
909
  * ctx surface). Mirrors getWorkflowFinalResult/getWorkflowPhaseState. The runner relies on the
811
910
  * `onCheckpoint` callback for accurate per-agent-call checkpoints (it captures the closure value
@@ -55,6 +55,12 @@ export interface RunDynamicWorkflowInput {
55
55
  modelOverride?: string;
56
56
  /** round-14 P1-2: per-workflow token budget. Overrides workflow.maxTokenBudget. */
57
57
  tokenBudget?: number;
58
+ /** SDD-3 W-C G13: cap on ctx.agent() invocations. Overrides
59
+ * workflow.maxAgentCalls; unset/invalid → DEFAULT_MAX_AGENT_CALLS.
60
+ * Accepts stringified numbers (schema Union numeric-string branch; the
61
+ * team-tool loose-numeric coercion list is owned by team-tool.ts, so this
62
+ * boundary coerces defensively). */
63
+ maxAgentCalls?: number | string;
58
64
  }
59
65
 
60
66
  export interface RunDynamicWorkflowResult {
@@ -152,6 +158,16 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
152
158
  return fn as DynamicWorkflowScript;
153
159
  }
154
160
 
161
+ /** SDD-3 W-C G13: resolve the effective agent-call cap from the runner input /
162
+ * workflow config. Accepts the schema's stringified-number form; anything
163
+ * non-finite or < 1 falls back to the ctx default (DEFAULT_MAX_AGENT_CALLS). */
164
+ function resolveMaxAgentCalls(value: number | string | undefined | null): number | undefined {
165
+ if (value === undefined || value === null || value === "") return undefined;
166
+ const n = typeof value === "number" ? value : Number(value);
167
+ if (!Number.isFinite(n) || n < 1) return undefined;
168
+ return Math.floor(n);
169
+ }
170
+
155
171
  /**
156
172
  * Run the dynamic workflow script. Loads it, builds the ctx, executes, and returns
157
173
  * {manifest, tasks} with the manifest updated to a terminal status + result artifact.
@@ -216,6 +232,9 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
216
232
  team: input.team,
217
233
  modelOverride: input.modelOverride,
218
234
  tokenBudget: input.tokenBudget ?? workflow.maxTokenBudget,
235
+ // SDD-3 W-C G13: explicit input wins over workflow config; both unset →
236
+ // ctx falls back to DEFAULT_MAX_AGENT_CALLS (a run is always bounded).
237
+ maxAgentCalls: resolveMaxAgentCalls(input.maxAgentCalls ?? workflow.maxAgentCalls),
219
238
  args: manifest.args,
220
239
  resumedState,
221
240
  // round-18 P2-3: checkpoint after each ctx.agent() call so a crash between calls
@@ -186,6 +186,37 @@ function safeDisposeLiveSession(handle: LiveAgentHandle): void {
186
186
  }
187
187
  }
188
188
 
189
+ /**
190
+ * WI-5 (SDD-4): last-resort kill for process-backed live sessions.
191
+ *
192
+ * terminateLiveAgent used to rely ONLY on session.abort()/dispose(). For
193
+ * in-process SDK sessions that is sufficient (nothing to signal), but a
194
+ * session handle that wraps a worker process and exposes `pid` (the same
195
+ * property evictStaleLiveAgentHandles already reads for liveness) can
196
+ * survive abort() — abort cancels the in-flight request, not the process —
197
+ * leaking a live worker process after terminate. Real child-pi workers never
198
+ * register here (they are killed via killProcessPid from the runner SIGTERM
199
+ * cascade, cancel/lifecycle asyncPid kill, and the stale reconciler); this
200
+ * covers registered handles that DO expose a worker pid. Guarded best-effort:
201
+ * skip non-numeric/self pids and already-dead processes; killProcessPid does
202
+ * the SIGTERM→SIGKILL escalation on the process group.
203
+ */
204
+ async function killLiveSessionProcessIfAlive(handle: LiveAgentHandle): Promise<void> {
205
+ try {
206
+ const session = handle.session as Record<string, unknown> | undefined;
207
+ const pid = session?.pid;
208
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || pid === process.pid) return;
209
+ if (!checkProcessLiveness(pid).alive) return;
210
+ // child-pi-kill is only needed on the terminate/cleanup path — keep it off
211
+ // the registration/render hot path (same pattern as background-runner).
212
+ // LAZY: dynamic import keeps child-pi-kill off the hot registration path.
213
+ const { killProcessPid } = await import("../child-pi/child-pi-kill.ts");
214
+ killProcessPid(pid);
215
+ } catch (error) {
216
+ logInternalError("live-agent-manager.kill-process", error, `agentId=${handle.agentId}`);
217
+ }
218
+ }
219
+
189
220
  export function disposeLiveAgentSession(agentIdOrTaskId: string): void {
190
221
  const handle = getLiveAgent(agentIdOrTaskId);
191
222
  if (!handle) return;
@@ -226,6 +257,10 @@ export async function terminateLiveAgent(
226
257
  safeDisposeLiveSession(handle);
227
258
  liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
228
259
  invalidateSortedLiveAgents();
260
+ // WI-5 (SDD-4): if the session still backs a live worker process that
261
+ // abort()/dispose() failed to stop, signal it last so terminate cannot
262
+ // leak the worker. No-op for in-process SDK sessions (no numeric pid).
263
+ await killLiveSessionProcessIfAlive(handle);
229
264
  }
230
265
  return handle;
231
266
  }
@@ -12,7 +12,7 @@ import type { WorkflowStep } from "../../workflows/workflow-config.ts";
12
12
  import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
13
13
  import { createIrcTool } from "../custom-tools/irc-tool.ts";
14
14
  import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
15
- import { buildMcpProxyFromSession } from "../mcp-proxy.ts";
15
+ import { g2EnforcementDegradationReason, mcpPermittedForRole, stripMcpExtensions } from "../mcp-proxy.ts";
16
16
  import {
17
17
  availableModelInfosFromRegistry,
18
18
  buildConfiguredModelRouting,
@@ -701,6 +701,16 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
701
701
 
702
702
  try {
703
703
  const agentDir = typeof mod.getAgentDir === "function" ? mod.getAgentDir() : undefined;
704
+ // G2 (SDD-2 W-B, least-privilege): live-session workers inherit the
705
+ // parent's MCP servers ONLY when the role is MCP-permitted
706
+ // (write-capable; read-only/unknown/undefined are default-denied —
707
+ // see mcpPermittedForRole). pi ≥0.99 loads MCP as a built-in
708
+ // EXTENSION (`builtin:mcp`, plus any installed replacer such as
709
+ // pi-mcp-adapter) and the pre-0.99 `enableMCP:false` session option
710
+ // no longer exists — so enforcement happens HERE, at the resource
711
+ // loader: the MCP extension(s) are dropped from the child's extension
712
+ // list for non-permitted roles (no MCP code load, no server connect).
713
+ const mcpPermitted = mcpPermittedForRole(input.task.role);
704
714
  let resourceLoader: unknown;
705
715
  // F1 (v0.7.9) NOTE: `agent.excludeExtensions` is applied on the
706
716
  // child-pi path (see `pi-args.ts`). The live-session path loads
@@ -718,9 +728,32 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
718
728
  noContextFiles: input.runtimeConfig?.inheritContext !== true,
719
729
  systemPromptOverride: () => liveSystemPrompt(input),
720
730
  appendSystemPromptOverride: () => [],
731
+ // G2: non-permitted roles get the MCP extension(s) stripped.
732
+ ...(mcpPermitted ? {} : { extensionsOverride: stripMcpExtensions }),
721
733
  });
722
734
  await (resourceLoader as { reload?: () => Promise<void> }).reload?.();
723
735
  }
736
+ // G2 degraded-enforcement warning (security-review follow-up): if the
737
+ // role is NOT MCP-permitted but the strip above could not be armed (SDK
738
+ // stopped exporting DefaultResourceLoader, or agentDir unresolvable),
739
+ // enforcement silently degrades to fail-open — surface it instead of
740
+ // letting a version drift quietly re-expose the parent's MCP servers.
741
+ const g2DegradationReason = g2EnforcementDegradationReason({
742
+ mcpPermitted,
743
+ resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
744
+ });
745
+ if (g2DegradationReason) {
746
+ appendEventFireAndForget(input.manifest.eventsPath, {
747
+ type: "task.mcp_enforcement_degraded",
748
+ runId: input.manifest.runId,
749
+ taskId: input.task.id,
750
+ 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.`,
751
+ data: {
752
+ role: input.task.role ?? null,
753
+ reason: g2DegradationReason,
754
+ },
755
+ });
756
+ }
724
757
  const effectiveParentModel = resolveParentModelFromRegistry(input.modelRegistry, input.parentModel);
725
758
  const modelRouting = buildConfiguredModelRouting({
726
759
  overrideModel: input.modelOverride,
@@ -759,9 +792,6 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
759
792
  warnOutOfScopeSoft(modelRouting.scopeVerdict, "live-session.model-out-of-scope");
760
793
  // H1.a: teamRole.thinking takes precedence over agent.thinking.
761
794
  const effectiveThinking = input.teamRoleThinking ?? input.agent.thinking;
762
- // Phase 4: MCP proxy — will be determined after session creation
763
- // (we check parent's MCP tools and share connections when available)
764
- const mcpProxy = buildMcpProxyFromSession([], { shareMcp: true });
765
795
 
766
796
  // G1: Build custom tools (submit_result + irc)
767
797
  const submitResultTool = createSubmitResultTool((result) => {
@@ -789,7 +819,6 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
789
819
  ...(input.modelRegistry ? { modelRegistry: input.modelRegistry } : {}),
790
820
  ...(resolvedModel ? { model: resolvedModel } : {}),
791
821
  ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
792
- ...(mcpProxy.enableMcp ? {} : { enableMCP: false }),
793
822
  customTools,
794
823
  });
795
824
  session = created.session;
@@ -9,15 +9,20 @@
9
9
  * Strategy:
10
10
  * 1. If the Pi SDK session has MCP tools after bindExtensions → use them directly
11
11
  * 2. If not → create proxy custom tools that wrap MCP calls
12
- * 3. If no MCP config exists → disable MCP in the session
12
+ * 3. If sharing is not permitted for the role → disable MCP in the session
13
13
  *
14
- * The Pi SDK's `createAgentSession` accepts a `customTools` array for injecting
15
- * proxy tools. The session also accepts `enableMCP: false` to skip MCP discovery
16
- * when proxying from the parent.
14
+ * G2 enforcement note (pi ≥0.99): MCP is now a BUILT-IN EXTENSION
15
+ * (`builtin:mcp`, reading `mcp.json`) and the pre-0.99 `enableMcp:false`
16
+ * `createAgentSession` option no longer exists. The runtime enforcement for
17
+ * a non-permitted role therefore drops the MCP extension(s) from the child's
18
+ * resource loader (`extensionsOverride` → `stripMcpExtensions` below).
19
+ * `McpProxyConfig.enableMcp` remains the module's semantic contract: callers
20
+ * can branch on it while E1 (real parent→child proxying) is unbuilt.
17
21
  */
18
22
 
19
23
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
20
24
  import type { TSchema } from "@sinclair/typebox";
25
+ import { permissionForRole } from "./role-permission.ts";
21
26
 
22
27
  export interface McpProxyConfig {
23
28
  /** Whether to enable MCP in the child session. */
@@ -32,11 +37,24 @@ export interface McpProxyConfig {
32
37
  * Build MCP proxy configuration for a live-session worker.
33
38
  *
34
39
  * @param options.parentMcpTools — MCP tool names from the parent session (if available)
35
- * @param options.shareMcp — Whether to share MCP connections (default: true)
40
+ * @param options.shareMcp — Whether to share MCP connections. MUST be explicit:
41
+ * `true` enables the discovery/proxy paths below; `false` or `undefined`
42
+ * denies (security-review follow-up, SDD-2 remediation — the old
43
+ * undefined-default fell through to the permissive enable paths).
36
44
  */
37
45
  export function buildMcpProxyConfig(options: { parentMcpTools?: string[]; shareMcp?: boolean }): McpProxyConfig {
38
- if (options.shareMcp === false) {
39
- return { enableMcp: true, proxyTools: [], proxyToolNames: [] };
46
+ if (options.shareMcp !== true) {
47
+ // G2 (SDD-2 W-B) + remediation hardening: least-privilege contract.
48
+ // shareMcp must be explicitly opted in — anything else (false OR
49
+ // undefined) means the child must not discover the parent's MCP
50
+ // servers AT ALL. The pre-flip behavior returned enableMcp:true for
51
+ // shareMcp:false (flag inversion), and the pre-hardening default let
52
+ // an OMITTED flag fall through to the permissive paths — either way
53
+ // even read-only roles self-discovered every parent MCP server (with
54
+ // credentials). These helpers currently have no production call
55
+ // sites; the hardened default exists so a future caller cannot
56
+ // re-open the gap by forgetting the flag.
57
+ return { enableMcp: false, proxyTools: [], proxyToolNames: [] };
40
58
  }
41
59
 
42
60
  const parentTools = options.parentMcpTools ?? [];
@@ -103,3 +121,88 @@ export function buildMcpProxyFromSession(activeToolNames: string[], options?: {
103
121
  shareMcp: options?.shareMcp,
104
122
  });
105
123
  }
124
+
125
+ /**
126
+ * G2 (SDD-2 W-B): the role-permission signal that decides `shareMcp` at the
127
+ * spawn sites. MCP servers carry the parent's credentials and can mutate
128
+ * state far outside the workspace, so only WRITE-capable roles may discover
129
+ * them. Read-only roles (explorer/reviewer/security-reviewer/analyst/
130
+ * critic/planner) are denied; unknown and undefined roles resolve to
131
+ * read-only via `permissionForRole`'s FIND-12 default-deny and are denied
132
+ * too. AgentConfig has no `mcp` field — this permission classification is
133
+ * the existing explicit signal (same source `filterActiveTools` uses).
134
+ */
135
+ export function mcpPermittedForRole(role: string | undefined): boolean {
136
+ return permissionForRole(role ?? "") !== "read_only";
137
+ }
138
+
139
+ /**
140
+ * Identify the extensions that provide MCP connectivity in pi ≥0.99:
141
+ * - `builtin:mcp` — the built-in MCP extension (reads `mcp.json`);
142
+ * - third-party extensions that REPLACE it by registering `/mcp` — pi's own
143
+ * docs name `pi-mcp-adapter` as the canonical replacer (docs/mcp.md:
144
+ * "replaces the built-in MCP support"). Both must be dropped to actually
145
+ * deny MCP; a path-segment match keeps npm-installed and file-path forms.
146
+ *
147
+ * KNOWN LIMITATION (security-review follow-up): this is a NAME-based
148
+ * denylist. A third-party MCP adapter that registers `/mcp` under a
149
+ * different name is NOT stripped and would re-enable discovery for
150
+ * non-permitted roles on the live-session path. An allowlist-shaped
151
+ * extension filter is the root fix — tracked as a follow-up work item
152
+ * (SDD-2 §13, G2 hardening backlog).
153
+ */
154
+ export function isMcpExtensionPath(extensionPath: string): boolean {
155
+ if (extensionPath === "builtin:mcp") return true;
156
+ return extensionPath.includes("pi-mcp-adapter");
157
+ }
158
+
159
+ /**
160
+ * G2 enforcement-degradation detector (security-review follow-up): the
161
+ * live-session runtime enforces the role MCP policy by stripping MCP
162
+ * extensions through `DefaultResourceLoader`'s `extensionsOverride` hook.
163
+ * If the SDK stops exporting `DefaultResourceLoader` (version drift) or the
164
+ * agent dir cannot be resolved, that strip silently degrades to fail-open
165
+ * and a non-permitted role would re-inherit every parent MCP server with
166
+ * no diagnostic. Callers should emit a warning event when this returns a
167
+ * reason instead of `undefined`.
168
+ */
169
+ export function g2EnforcementDegradationReason(input: {
170
+ mcpPermitted: boolean;
171
+ resourceLoaderAvailable: boolean;
172
+ }): "sdk_loader_missing" | undefined {
173
+ if (input.mcpPermitted) return undefined;
174
+ return input.resourceLoaderAvailable ? undefined : "sdk_loader_missing";
175
+ }
176
+
177
+ /** Structural stand-in for the SDK's `LoadExtensionsResult` (loose typing —
178
+ * the live-session runtime holds the SDK behind `Record<string, unknown>`). */
179
+ export interface LoadedExtensionsShape {
180
+ extensions: Array<{ path: string; [key: string]: unknown }>;
181
+ errors?: unknown;
182
+ warnings?: Array<{ path: string; warning: string }>;
183
+ runtime?: unknown;
184
+ [key: string]: unknown;
185
+ }
186
+
187
+ /**
188
+ * `DefaultResourceLoader` `extensionsOverride` hook (G2 enforcement on
189
+ * pi ≥0.99): drop every MCP-providing extension from the child's extension
190
+ * list so a non-permitted role neither loads the MCP code nor connects to
191
+ * any server. Stripped paths are recorded as warnings for diagnostics.
192
+ */
193
+ export function stripMcpExtensions(base: LoadedExtensionsShape): LoadedExtensionsShape {
194
+ const kept = base.extensions.filter((extension) => !isMcpExtensionPath(extension.path));
195
+ if (kept.length === base.extensions.length) return base;
196
+ const stripped = base.extensions.filter((extension) => isMcpExtensionPath(extension.path));
197
+ return {
198
+ ...base,
199
+ extensions: kept,
200
+ warnings: [
201
+ ...(base.warnings ?? []),
202
+ ...stripped.map((extension) => ({
203
+ path: extension.path,
204
+ warning: "stripped by pi-crew G2 role MCP policy (role is not MCP-permitted)",
205
+ })),
206
+ ],
207
+ };
208
+ }
@@ -15,7 +15,6 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
15
15
  // find pi-crew's package.json and works correctly from both src/ and
16
16
  // dist/ entry points.
17
17
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(packageRoot(), "src", "prompt", "prompt-runtime.ts");
18
- const TASK_ARG_LIMIT = 8000;
19
18
  // D8 spec v0.7 — nested mở (default 4 depth levels: child→child→child→child);
20
19
  // cap giữ để chống runaway recursion từ config lỗi hoặc intentional abuse.
21
20
  const DEFAULT_MAX_CREW_DEPTH = 4;
@@ -348,17 +347,21 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
348
347
  args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath);
349
348
  }
350
349
 
351
- if (input.task.length > TASK_ARG_LIMIT) {
352
- if (!tempDir) {
353
- const tmpBase = getPiTempBase();
354
- tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
355
- }
356
- const taskPath = path.join(tempDir, "task.md");
357
- atomicWriteFile(taskPath, input.task, { mode: 0o600 });
358
- args.push(`@${taskPath}`);
359
- } else {
360
- args.push(`Task: ${input.task}`);
350
+ // G3 (SDD-2 W-B, spill-always): task text NEVER rides argv — argv is
351
+ // world-readable via /proc/<pid>/cmdline, leaking task content (and any
352
+ // secrets embedded in prompts) to every local user/process scanner.
353
+ // EVERY task, regardless of length, is spilled to an owner-only (0600)
354
+ // task.md inside pi-crew's temp dir and passed as a `@<path>` inclusion
355
+ // arg. The pre-G3 short-argv branch (task <= 8000 → `Task: ...` positional)
356
+ // is removed; the file content is the RAW task text (same contract the
357
+ // long-task path always had — no `Task: ` prefix).
358
+ if (!tempDir) {
359
+ const tmpBase = getPiTempBase();
360
+ tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
361
361
  }
362
+ const taskPath = path.join(tempDir, "task.md");
363
+ atomicWriteFile(taskPath, input.task, { mode: 0o600 });
364
+ args.push(`@${taskPath}`);
362
365
 
363
366
  const env = input.env ?? process.env;
364
367
  const parentDepth = currentCrewDepth(env);