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.
- package/CHANGELOG.md +109 -0
- package/README.md +2 -2
- package/dist/index.mjs +498 -492
- package/docs/actions-reference.md +22 -0
- package/docs/dynamic-workflows.md +37 -0
- package/index.ts +9 -5
- package/package.json +18 -14
- package/scripts/analyze-run.mjs +101 -14
- package/skills/model-routing-context/SKILL.md +4 -3
- package/skills/real-test-pi-crew/SKILL.md +10 -7
- package/src/config/env-vars.ts +6 -0
- package/src/extension/crew-vibes/index.ts +29 -10
- package/src/extension/pi-api.ts +1 -0
- package/src/extension/registration/lifecycle-handlers.ts +65 -3
- package/src/extension/team-tool/dispatch/index.ts +4 -3
- package/src/extension/team-tool/dispatch/status.ts +7 -3
- package/src/extension/team-tool/inspect.ts +90 -0
- package/src/extension/team-tool/respond.ts +54 -0
- package/src/extension/team-tool/status.ts +10 -0
- package/src/extension/team-tool.ts +393 -3
- package/src/extension/webhook-notify.ts +23 -1
- package/src/observability/exporters/otlp-exporter.ts +93 -11
- package/src/prompt/prompt-runtime.ts +13 -4
- package/src/runtime/agent-control.ts +1 -15
- package/src/runtime/background-runner.ts +3 -0
- package/src/runtime/broker/crew-broker.ts +12 -103
- package/src/runtime/broker/protocol/request-parsers.ts +9 -1
- package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
- package/src/runtime/dispatch-batch.ts +3 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
- package/src/runtime/live-session/live-agent-manager.ts +35 -0
- package/src/runtime/live-session/live-session-runtime.ts +34 -5
- package/src/runtime/mcp-proxy.ts +110 -7
- package/src/runtime/model/pi-args.ts +14 -11
- package/src/runtime/model/provider-extensions.ts +67 -1
- package/src/runtime/recovery/crash-recovery.ts +6 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
- package/src/runtime/stale-reconciler.ts +55 -0
- package/src/runtime/task-output-context.ts +11 -1
- package/src/runtime/task-runner/child-executor.ts +130 -4
- package/src/runtime/task-runner/output-splitter.ts +48 -5
- package/src/runtime/task-runner/post-execution.ts +2 -2
- package/src/runtime/task-runner/pre-execution.ts +37 -24
- package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
- package/src/schema/team-tool-schema.ts +27 -1
- package/src/state/contracts.ts +16 -0
- package/src/state/crew-init.ts +114 -0
- package/src/state/stores/active-run-registry.ts +13 -6
- package/src/state/types.ts +0 -8
- package/src/ui/goal-flag.ts +44 -0
- package/src/ui/live-run-sidebar.ts +5 -1
- package/src/ui/pi-ui-compat.ts +20 -3
- package/src/ui/run-dashboard.ts +2 -1
- package/src/ui/widget/widget-model.ts +6 -2
- package/src/utils/ip-literal.ts +145 -0
- 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:
|
|
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
|
|
303
|
-
|
|
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() <
|
|
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 +=
|
|
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:
|
|
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 -=
|
|
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 <
|
|
422
|
-
wfState.spent += (parsed.usage?.input ?? 0) + (parsed.usage?.output ?? 0) -
|
|
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 -=
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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;
|
package/src/runtime/mcp-proxy.ts
CHANGED
|
@@ -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
|
|
12
|
+
* 3. If sharing is not permitted for the role → disable MCP in the session
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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);
|