pi-crew 0.11.6 → 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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * rpc-worker.ts — W7 (P2-3): EXPERIMENTAL RPC worker transport (prototype).
3
+ *
4
+ * STATUS — NOT WIRED INTO LIVE DISPATCH (honest prototype):
5
+ * `runWorker` (run-worker.ts) consults {@link resolveWorkerTransport}; when it
6
+ * resolves "rpc" the seam currently returns a structured not-implemented
7
+ * result instead of calling {@link runRpcWorker}. Reason: this lane is
8
+ * forbidden from live-spawning pi (Round-2 evidence already covers the live
9
+ * probes; unit tests use fake streams only), so the live path would ship
10
+ * completely unvalidated. Flipping the seam to call runRpcWorker is the
11
+ * integration-phase follow-up AFTER a live-fire probe (see README.md).
12
+ *
13
+ * runRpcWorker itself is complete and fake-stream tested:
14
+ * prompt → collect session events until `agent_settled` → orderly stop →
15
+ * map to ChildPiRunResult. Abort/steer signals send the RPC `abort` command
16
+ * before stopping.
17
+ */
18
+
19
+ import { getCrewEnv } from "../../config/env-vars.ts";
20
+ import type { ChildPiRunResult } from "../child-pi/child-pi.ts";
21
+ import { createRpcFrameClient, type RpcFrameClientOptions } from "./frame-client.ts";
22
+ import type { DialogAnswerPolicy } from "./ui-request-policy.ts";
23
+
24
+ /** Env knob selecting the transport at the run-worker seam. */
25
+ export const WORKER_TRANSPORT_ENV = "PI_CREW_WORKER_TRANSPORT";
26
+ /** Env knob for the dialog auto-answer policy (GATE 2). */
27
+ export const RPC_DIALOG_ANSWER_ENV = "PI_CREW_RPC_DIALOG_ANSWER";
28
+
29
+ export type WorkerTransport = "stdio" | "rpc";
30
+
31
+ /** Minimal surface of runWorker's input the RPC path consumes. */
32
+ export interface RpcWorkerInput {
33
+ cwd?: string;
34
+ task: string;
35
+ model?: string;
36
+ signal?: AbortSignal;
37
+ /** Optional client overrides (tests inject a fake spawner + tiny timeouts). */
38
+ rpc?: Pick<RpcFrameClientOptions, "spawnFn" | "argv" | "commandTimeoutMs" | "stopTimeoutMs" | "dialogPolicy">;
39
+ }
40
+
41
+ /**
42
+ * Resolve the worker transport. The env var is the live prototype gate
43
+ * (runWorker has no config access — packet §A); the `runtime.workerTransport`
44
+ * config key is declared in config-schema.ts/types.ts and reaches this seam
45
+ * in the integration phase. FAILSAFE: anything other than exactly "rpc"
46
+ * (after trim) resolves to the default stdio transport.
47
+ */
48
+ export function resolveWorkerTransport(read: (name: string) => string | undefined = getCrewEnv): WorkerTransport {
49
+ const raw = read(WORKER_TRANSPORT_ENV);
50
+ return raw?.trim() === "rpc" ? "rpc" : "stdio";
51
+ }
52
+
53
+ /**
54
+ * Resolve the dialog auto-answer policy (GATE 2). "cancel" (default) is the
55
+ * universal safe answer; "block" leaves the server promise pending on purpose
56
+ * (debugging). Auto-confirm is deliberately NOT a policy (security gate — see
57
+ * ui-request-policy.ts). Invalid values fail safe to "cancel".
58
+ */
59
+ export function resolveDialogAnswerPolicy(read: (name: string) => string | undefined = getCrewEnv): DialogAnswerPolicy {
60
+ return read(RPC_DIALOG_ANSWER_ENV)?.trim() === "block" ? "block" : "cancel";
61
+ }
62
+
63
+ interface SettleTracking {
64
+ lastAssistantText: string | undefined;
65
+ settled: boolean;
66
+ }
67
+
68
+ function extractAssistantText(message: unknown): string | undefined {
69
+ if (typeof message !== "object" || message === null) return undefined;
70
+ const role = (message as { role?: unknown }).role;
71
+ if (role !== "assistant") return undefined;
72
+ const content = (message as { content?: unknown }).content;
73
+ if (!Array.isArray(content)) return undefined;
74
+ const parts: string[] = [];
75
+ for (const part of content) {
76
+ if (typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text") {
77
+ const text = (part as { text?: unknown }).text;
78
+ if (typeof text === "string") parts.push(text);
79
+ }
80
+ }
81
+ return parts.length > 0 ? parts.join("\n") : undefined;
82
+ }
83
+
84
+ /**
85
+ * Run one worker turn over the RPC frame transport (EXPERIMENTAL prototype —
86
+ * see module header). Never spawned by production dispatch in this lane.
87
+ */
88
+ export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunResult> {
89
+ const tracking: SettleTracking = { lastAssistantText: undefined, settled: false };
90
+ let settleResolve: (() => void) | undefined;
91
+ const settledPromise = new Promise<void>((resolve) => {
92
+ settleResolve = resolve;
93
+ });
94
+ let abortError: Error | undefined;
95
+
96
+ const overrides = input.rpc ?? {};
97
+ const client = createRpcFrameClient({
98
+ ...overrides,
99
+ dialogPolicy: overrides.dialogPolicy ?? resolveDialogAnswerPolicy(),
100
+ onEvent: (event) => {
101
+ const record = event as { type?: unknown; message?: unknown };
102
+ if (record?.type === "message_end") {
103
+ const text = extractAssistantText(record.message);
104
+ if (text !== undefined) tracking.lastAssistantText = text;
105
+ } else if (record?.type === "agent_settled") {
106
+ tracking.settled = true;
107
+ settleResolve?.();
108
+ }
109
+ },
110
+ });
111
+
112
+ const onAbort = () => {
113
+ // Ask the server to abort the current run, then stop orderly. The
114
+ // command may race the shutdown — a rejection is expected and logged.
115
+ client
116
+ .sendCommand({ type: "abort" })
117
+ .catch((error: Error) => {
118
+ abortError = error;
119
+ })
120
+ .finally(() => {
121
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: stop() never rejects by design — void-append .catch per lane rules.
122
+ void client.stop().catch(() => {});
123
+ settleResolve?.();
124
+ });
125
+ };
126
+ input.signal?.addEventListener("abort", onAbort, { once: true });
127
+
128
+ let runError: string | undefined;
129
+ try {
130
+ await client.sendCommand({ type: "prompt", message: input.task });
131
+ await settledPromise;
132
+ } catch (error) {
133
+ runError = error instanceof Error ? error.message : String(error);
134
+ } finally {
135
+ input.signal?.removeEventListener("abort", onAbort);
136
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: stop() never rejects by design — void-append .catch per lane rules.
137
+ await client.stop().catch(() => {});
138
+ }
139
+
140
+ const exitCode = await client.exited();
141
+ const result: ChildPiRunResult = {
142
+ exitCode,
143
+ stdout: client.stdoutText(),
144
+ stderr: client.stderrText(),
145
+ };
146
+ if (runError) result.error = runError;
147
+ else if (abortError && !tracking.settled) result.error = `rpc abort failed: ${abortError.message}`;
148
+ if (tracking.lastAssistantText !== undefined) result.rawFinalText = tracking.lastAssistantText;
149
+ if (input.signal?.aborted) result.aborted = true;
150
+ return result;
151
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ui-request-policy.ts — W7 (P2-3) RPC transport: the two Round-2 design gates.
3
+ *
4
+ * Evidence base (read directly from the SDK's rpc-mode.js during W7 recon):
5
+ *
6
+ * GATE 1 — extension_ui_request DRAIN (fire-and-forget methods):
7
+ * `notify` (rpc-mode.js:87-96), `setStatus` (:101-110), `setWidget`
8
+ * (:123+), `setTitle`, `set_editor_text` are emitted WITHOUT being
9
+ * registered in `pendingExtensionRequests` — the server never waits for
10
+ * a response. Round-2 live evidence: 15 of the first 16 records a worker
11
+ * emits are these. Drain policy = COUNT + DROP (a response would be
12
+ * harmlessly ignored by the server, but sending one is pure waste).
13
+ *
14
+ * GATE 2 — DIALOG auto-answer (ask-over-RPC otherwise blocks forever):
15
+ * `select` / `confirm` / `input` (:84-86) and `editor` (:191) register a
16
+ * promise in `pendingExtensionRequests` (:70-76). If the client never
17
+ * answers and the request has no `timeout`, the server-side promise NEVER
18
+ * resolves — the worker deadlocks (R2.5 ask-tool deadlock caveat).
19
+ * Universal safe answer: `{type:"extension_ui_response", id, cancelled:true}`
20
+ * → server resolves select/input to undefined and confirm to false.
21
+ *
22
+ * SECURITY GATE (deliberate, review-mandated): there is NO auto-approve /
23
+ * auto-confirm mode. An unattended transport must never answer "yes" on the
24
+ * user's behalf. If a confirm-approval mode is ever needed it must be an
25
+ * explicit opt-in with its own security review — do not add it here.
26
+ */
27
+
28
+ import type { RpcExtensionUIRequest, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
29
+
30
+ /** Dialog answer policy. See module header GATE 2 + SECURITY GATE. */
31
+ export type DialogAnswerPolicy = "cancel" | "block";
32
+
33
+ /** UI-request methods the server emits fire-and-forget (never pending). */
34
+ export const FIRE_AND_FORGET_UI_METHODS: ReadonlySet<string> = new Set(["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"]);
35
+
36
+ /** UI-request methods that register a pending server-side promise (block until answered). */
37
+ export const DIALOG_UI_METHODS: ReadonlySet<string> = new Set(["select", "confirm", "input", "editor"]);
38
+
39
+ /** Counters surfaced for diagnostics + tests (packet §E instrumentation set). */
40
+ export interface UiRequestCounters {
41
+ /** Fire-and-forget records drained (counted, then dropped). */
42
+ uiRequestsDrained: number;
43
+ /** Drain breakdown by method (e.g. setStatus / setWidget). */
44
+ drainedByMethod: Record<string, number>;
45
+ /** Dialogs answered with the universal safe answer (`cancelled:true`). */
46
+ dialogsCancelled: number;
47
+ /** Dialogs left unanswered on purpose (policy "block" — server promise stays pending). */
48
+ dialogsBlocked: number;
49
+ }
50
+
51
+ export interface UiRequestPolicy {
52
+ /** Apply the drain/answer policy to one incoming ui-request record. */
53
+ handle(request: RpcExtensionUIRequest): void;
54
+ /** Snapshot of the counters (fresh object each call). */
55
+ counters(): UiRequestCounters;
56
+ }
57
+
58
+ export interface UiRequestPolicyOptions {
59
+ /** GATE 2 policy. "cancel" (default): answer `cancelled:true`. "block": leave pending (debug). */
60
+ dialogPolicy: DialogAnswerPolicy;
61
+ /** Sink for ui-responses the policy decides to send (the client's stdin writer). */
62
+ writeResponse: (response: RpcExtensionUIResponse) => void;
63
+ /** Optional warn sink (policy decisions worth surfacing). */
64
+ onWarn?: (message: string) => void;
65
+ }
66
+
67
+ /**
68
+ * Build the drain/answer policy for one RPC frame client.
69
+ *
70
+ * Unknown methods (future SDK additions) are treated as fire-and-forget:
71
+ * counted into `uiRequestsDrained` and dropped — never answered, because we
72
+ * cannot know whether the server put them in the pending map. A stray answer
73
+ * is harmless to the server, but staying silent is the conservative default
74
+ * for anything we have not classified.
75
+ */
76
+ export function createUiRequestPolicy(options: UiRequestPolicyOptions): UiRequestPolicy {
77
+ const dialogPolicy = options.dialogPolicy === "block" ? "block" : "cancel";
78
+ const drainedByMethod: Record<string, number> = {};
79
+ let uiRequestsDrained = 0;
80
+ let dialogsCancelled = 0;
81
+ let dialogsBlocked = 0;
82
+
83
+ return {
84
+ handle(request: RpcExtensionUIRequest): void {
85
+ const method: string = request.method;
86
+ if (DIALOG_UI_METHODS.has(method)) {
87
+ if (dialogPolicy === "block") {
88
+ dialogsBlocked++;
89
+ options.onWarn?.(
90
+ `rpc dialog ${method} (${request.id}) left unanswered by policy "block" — server promise stays pending`,
91
+ );
92
+ return;
93
+ }
94
+ dialogsCancelled++;
95
+ // Universal safe answer: select/input → undefined, confirm → false.
96
+ options.writeResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
97
+ return;
98
+ }
99
+ // GATE 1: fire-and-forget (or unknown) — count + drop.
100
+ uiRequestsDrained++;
101
+ drainedByMethod[method] = (drainedByMethod[method] ?? 0) + 1;
102
+ },
103
+ counters(): UiRequestCounters {
104
+ return {
105
+ uiRequestsDrained,
106
+ drainedByMethod: { ...drainedByMethod },
107
+ dialogsCancelled,
108
+ dialogsBlocked,
109
+ };
110
+ },
111
+ };
112
+ }
@@ -28,6 +28,7 @@
28
28
 
29
29
  import type { ChildPiRunInput, ChildPiRunResult } from "./child-pi/child-pi.ts";
30
30
  import { runChildPi } from "./child-pi/child-pi.ts";
31
+ import { resolveWorkerTransport } from "./rpc/rpc-worker.ts";
31
32
  import { withWorkerSlot } from "./scheduling/global-worker-cap.ts";
32
33
 
33
34
  /**
@@ -71,6 +72,19 @@ export interface WorkerSpawnInput extends ChildPiRunInput {
71
72
  */
72
73
  export async function runWorker(input: WorkerSpawnInput): Promise<ChildPiRunResult> {
73
74
  const { cap = true, ...childPiInput } = input;
75
+ // W7 (P2-3) EXPERIMENTAL transport seam: PI_CREW_WORKER_TRANSPORT=rpc
76
+ // selects the prototype RPC transport. NOT wired live yet — the module
77
+ // (src/runtime/rpc/, fake-stream tested only) stands beside production and
78
+ // the seam returns a structured not-implemented result so nobody can
79
+ // mistake the prototype for an active transport. Default/invalid env →
80
+ // stdio path below, byte-identical to the pre-W7 behavior. No worker-cap
81
+ // slot is taken for the not-implemented return (nothing spawns).
82
+ if (resolveWorkerTransport() === "rpc") {
83
+ const message =
84
+ "rpc worker transport requested (PI_CREW_WORKER_TRANSPORT=rpc) but live wiring is not implemented yet — W7 prototype module src/runtime/rpc/ is fake-stream tested only; unset PI_CREW_WORKER_TRANSPORT to use the default stdio transport";
85
+ console.error(`[pi-crew:run-worker.rpc-transport] ${message}`);
86
+ return { exitCode: null, stdout: "", stderr: "", error: message };
87
+ }
74
88
  if (cap) {
75
89
  // RR-014 / F15: thread the caller's signal into the slot WAIT itself.
76
90
  // Previously the signal lived only inside childPiInput and was read by
@@ -270,7 +270,14 @@ export async function prepareSurfaceSpawn(input: PrepareSurfaceSpawnInput): Prom
270
270
  throw new Error("provider does not implement sendCommand — cannot boot a commandless pane");
271
271
  }
272
272
  const tuiArgs = stripHeadlessModeArgs(input.piArgs);
273
- const spawnSpec = (input.deps?.resolveCommand ?? getPiSpawnCommand)(tuiArgs);
273
+ // P0-1 (pi 1.0.0 — review R2.1 2026-10-03): pin TUI mode `regular`.
274
+ // 1.0.0 đổi default sang fullscreen; alt-screen làm rỗng scrollback mà
275
+ // readScreen (tmux capture-pane -S) đang scrape → mất history pane.
276
+ // Flag có từ pi 0.84.0, dưới tested floor 0.99.2 của pi-crew → thêm vô
277
+ // điều kiện (pi strict parser REJECT flag lạ — note buildPiWorkerArgs,
278
+ // pi-args.ts). Caller đã set --tui-mode thì tôn trọng, không nhân đôi.
279
+ const tuiSpawnArgs = tuiArgs.includes("--tui-mode") ? tuiArgs : [...tuiArgs, "--tui-mode", "regular"];
280
+ const spawnSpec = (input.deps?.resolveCommand ?? getPiSpawnCommand)(tuiSpawnArgs);
274
281
  // Env export = worker env đầy đủ (parity headless) + các biến surface.
275
282
  // Không stateRoot (spawn ngoài run) → không có event log để ghi — bỏ key
276
283
  // thay vì đường dẫn tương đối sai layout agents/{taskId}/events.jsonl.
@@ -41,6 +41,12 @@ export interface DependencyContextEntry {
41
41
  * the workflow (earlier deps = higher relevance because they were
42
42
  * explicitly listed first). Defaults to 0.5 (neutral) when unknown. */
43
43
  relevance?: number;
44
+ /** Handoff-budget marker: set by {@link applyHandoffBudget} when this
45
+ * entry was downgraded to the compact form (taskId/role/status + summary
46
+ * head ≤240 chars + artifact pointer). The render path emits the compact
47
+ * block for marked entries; `writeTaskInputsArtifact` still serializes the
48
+ * ORIGINAL (untrimmed) context — the trim is render-only, never persisted. */
49
+ budgetTrimmed?: boolean;
44
50
  }
45
51
 
46
52
  export interface DependencyOutputContext {
@@ -58,6 +64,12 @@ export interface DependencyOutputContext {
58
64
  content: string;
59
65
  fullOutputPath?: string;
60
66
  }>;
67
+ /** Run id (from the team manifest). Populated by
68
+ * `collectDependencyOutputContext` so the handoff-budget trim can build
69
+ * artifact pointers (`artifacts/<runId>/results/<taskId>.txt`). Optional
70
+ * for backward-compat with hand-built fixtures; without it trimmed deps
71
+ * simply omit the pointer line. */
72
+ runId?: string;
61
73
  }
62
74
 
63
75
  function containedExists(filePath: string, baseDir?: string): boolean {
@@ -523,7 +535,7 @@ export function collectDependencyOutputContext(
523
535
  // outputs with compact digest notices before injecting into the worker
524
536
  // prompt. OPT-IN: default config protects recent results.
525
537
  const sharedReads = pruneSharedReads(rawSharedReads, trimmedDependencies, manifest.artifactsRoot);
526
- return { dependencies: trimmedDependencies, sharedReads };
538
+ return { dependencies: trimmedDependencies, sharedReads, runId: manifest.runId };
527
539
  }
528
540
 
529
541
  // ADR-5 §trust-fence: dependency output is DATA, never instructions. Mirror the
@@ -536,46 +548,193 @@ function sanitizeFencedBody(body: string): string {
536
548
  return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
537
549
  }
538
550
 
539
- export function renderDependencyOutputContext(context: DependencyOutputContext): string {
551
+ // ── Handoff budget (est-token cap on this layer) ─────────────────────────────
552
+
553
+ /** Default est-token budget for the dynamic.dependencyContext layer. */
554
+ export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
555
+ /** Budget ceiling: a resolved budget above this is treated as the off-switch
556
+ * (same semantics as ≤0 — clearly not a real handoff cap, so render
557
+ * untrimmed instead of guessing). */
558
+ export const HANDOFF_BUDGET_CEILING_TOKENS = 1_000_000;
559
+ /** Summary head kept per dependency when the budget trims it. */
560
+ export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
561
+
562
+ /** Strict integer env syntax — "12abc" must NOT parse as 12. */
563
+ const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
564
+
565
+ /** Est tokens = chars/4 — the same heuristic as `estimateTokens` in
566
+ * src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
567
+ * here instead of imported: importing prompt-builder would pull its
568
+ * retrieval/workspace-tree graph into every task-output-context consumer
569
+ * (post-execution, aggregate outputs) for a one-line heuristic. Keep the
570
+ * two in sync if either changes. */
571
+ function estimateHandoffTokens(chars: number): number {
572
+ return Math.round(chars / 4);
573
+ }
574
+
575
+ /** Budget active iff it is a positive finite number at or below the ceiling;
576
+ * ≤0 or >ceiling = off-switch (render untrimmed). */
577
+ function handoffBudgetActive(budgetTokens: number): boolean {
578
+ return Number.isFinite(budgetTokens) && budgetTokens > 0 && budgetTokens <= HANDOFF_BUDGET_CEILING_TOKENS;
579
+ }
580
+
581
+ /** Resolve the handoff budget: env PI_CREW_HANDOFF_BUDGET_TOKENS beats the
582
+ * configured value, which beats the default (1800). A non-numeric/invalid
583
+ * env string NEVER throws — it falls through to config/default. The resolved
584
+ * number may still be ≤0 or >1_000_000 (off-switch at the render site). */
585
+ export function resolveHandoffBudgetTokens(configured: number | undefined): number {
586
+ const raw = getCrewEnv("PI_CREW_HANDOFF_BUDGET_TOKENS");
587
+ if (raw !== undefined) {
588
+ const trimmed = raw.trim();
589
+ if (HANDOFF_BUDGET_ENV_RE.test(trimmed)) {
590
+ const parsed = Number.parseInt(trimmed, 10);
591
+ if (Number.isFinite(parsed)) return parsed;
592
+ }
593
+ }
594
+ return configured ?? DEFAULT_HANDOFF_BUDGET_TOKENS;
595
+ }
596
+
597
+ /** Full-form render lines for one dependency (pre-sanitize). */
598
+ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
599
+ const lines: string[] = [
600
+ `## ${dep.taskId} (${dep.role})`,
601
+ `Status: ${dep.status}`,
602
+ dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
603
+ "",
604
+ dep.resultSummary?.trim() || "(no result output)",
605
+ "",
606
+ ];
607
+ // P1-A dependency tee-recovery hint: when the dependency's result was
608
+ // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
609
+ // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
610
+ // downstream worker can read the dropped middle instead of re-deriving.
611
+ if (dep.fullOutputPath) lines.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
612
+ if (dep.structuredResults) lines.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
613
+ if (dep.artifactsProduced?.length) lines.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
614
+ if (dep.usage)
615
+ lines.push(`Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`, "");
616
+ return lines;
617
+ }
618
+
619
+ /** Compact handoff-budget form for one dependency (pre-sanitize): keeps the
620
+ * taskId/role/status lines + a ≤240-char summary head (with a truncation
621
+ * marker when the summary was longer) + a relative artifact pointer the
622
+ * worker can `read`. Drops structuredResults, usage, artifactsProduced and
623
+ * the absolute-path lines — all recoverable via the pointer. The pointer is
624
+ * built ONLY from manifest-derived runId/taskId (same trust level as the
625
+ * existing `## <taskId>` headers and the results/<taskId>.txt artifact
626
+ * naming); dep-controlled content never reaches it. */
627
+ function budgetTrimmedDependencyEntryLines(dep: DependencyContextEntry, runId: string | undefined): string[] {
628
+ const summary = dep.resultSummary?.trim() ?? "";
629
+ const head = summary.slice(0, HANDOFF_SUMMARY_HEAD_CHARS);
630
+ const body =
631
+ summary.length > HANDOFF_SUMMARY_HEAD_CHARS ? `${head}\n[trimmed, ${summary.length} chars total]` : summary || "(no result output)";
632
+ return [
633
+ `## ${dep.taskId} (${dep.role})`,
634
+ `Status: ${dep.status}`,
635
+ body,
636
+ ...(runId ? [`full output: artifacts/${runId}/results/${dep.taskId}.txt`] : []),
637
+ "",
638
+ ];
639
+ }
640
+
641
+ /** Full-form render lines for one shared read (pre-sanitize). */
642
+ function sharedReadLines(read: { name: string; path: string; content: string; fullOutputPath?: string }): string[] {
643
+ const lines = [`## shared/${read.name}`, `Path: ${read.path}`];
644
+ // P1-A tee-recovery hint: when the file was materially truncated
645
+ // (>2× threshold) the full content was teed to fullOutputPath so the
646
+ // worker can read the dropped middle if needed. The path is inside
647
+ // artifactsRoot/tee/ and goes through the normal permission gate.
648
+ if (read.fullOutputPath) lines.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
649
+ lines.push("", read.content.trim(), "");
650
+ return lines;
651
+ }
652
+
653
+ /** Build the unsanitized body parts for a (possibly budget-trimmed) context.
654
+ * Shared by the renderer (sanitize path) and the budget estimator so the
655
+ * estimate can never drift from what is actually rendered. */
656
+ function buildDependencyOutputBodyParts(context: DependencyOutputContext): string[] {
540
657
  const parts: string[] = [];
541
658
  if (context.dependencies.length) {
542
659
  parts.push("# Dependency Outputs", "");
543
660
  for (const dep of context.dependencies) {
544
- parts.push(
545
- `## ${dep.taskId} (${dep.role})`,
546
- `Status: ${dep.status}`,
547
- dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
548
- "",
549
- dep.resultSummary?.trim() || "(no result output)",
550
- "",
551
- );
552
- // P1-A dependency tee-recovery hint: when the dependency's result was
553
- // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
554
- // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
555
- // downstream worker can read the dropped middle instead of re-deriving.
556
- if (dep.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
557
- if (dep.structuredResults) parts.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
558
- if (dep.artifactsProduced?.length) parts.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
559
- if (dep.usage)
560
- parts.push(
561
- `Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`,
562
- "",
563
- );
661
+ parts.push(...(dep.budgetTrimmed ? budgetTrimmedDependencyEntryLines(dep, context.runId) : dependencyEntryLines(dep)));
564
662
  }
565
663
  }
566
664
  if (context.sharedReads.length) {
567
665
  parts.push("# Shared Run Context Reads", "");
568
- for (const read of context.sharedReads) {
569
- parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
570
- // P1-A tee-recovery hint: when the file was materially truncated
571
- // (>2× threshold) the full content was teed to fullOutputPath so the
572
- // worker can read the dropped middle if needed. The path is inside
573
- // artifactsRoot/tee/ and goes through the normal permission gate.
574
- if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
575
- parts.push("", read.content.trim(), "");
576
- }
666
+ for (const read of context.sharedReads) parts.push(...sharedReadLines(read));
577
667
  }
578
- return sanitizeFencedBody(parts.join("\n").trim());
668
+ return parts;
669
+ }
670
+
671
+ /** Measure a candidate context in est tokens (chars/4), mirroring the render
672
+ * byte-for-byte (same parts builder; `.trim()` matches the render join). */
673
+ function measureHandoffTokens(context: DependencyOutputContext): number {
674
+ return estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length);
675
+ }
676
+
677
+ /**
678
+ * Handoff budget: trim the dependency-output context to an est-token
679
+ * (chars/4) budget. PURE — no I/O, no env reads (callers pass the resolved
680
+ * budget from {@link resolveHandoffBudgetTokens}); returns the input
681
+ * reference unchanged when the budget is off (≤0 or >1_000_000) or the
682
+ * context already fits.
683
+ *
684
+ * Trim policy (declaration order): dependencies are probed from ALL-full
685
+ * downward — the largest prefix of dependencies that still fits keeps its
686
+ * FULL output; every dependency after the cutoff is downgraded to the
687
+ * compact form (taskId/role/status + ≤240-char summary head + truncation
688
+ * marker + `artifacts/<runId>/results/<taskId>.txt` pointer). Earlier deps
689
+ * therefore always keep ≥ as much as later ones. Long (>240 chars) sharedRead
690
+ * bodies are replaced by a trim marker (the Path pointer is already
691
+ * rendered); short shared reads pass through.
692
+ *
693
+ * When even the fully-trimmed form exceeds the budget (extreme dependency
694
+ * count), the fully-trimmed context is returned as the bounded best effort —
695
+ * every entry is then ≤ ~240 chars + pointer, so the layer stays small and
696
+ * deterministic.
697
+ *
698
+ * Composes AFTER the L4 byte trim (`enforceDependencyInlineBudget`, 96KB,
699
+ * priority-ordered path-only downgrade) which runs at collect time and is
700
+ * untouched by this function.
701
+ */
702
+ export function applyHandoffBudget(context: DependencyOutputContext, budgetTokens: number, runId?: string): DependencyOutputContext {
703
+ if (!handoffBudgetActive(budgetTokens)) return context;
704
+ const effectiveRunId = runId ?? context.runId;
705
+ const base: DependencyOutputContext =
706
+ effectiveRunId !== undefined && context.runId !== effectiveRunId ? { ...context, runId: effectiveRunId } : context;
707
+ if (measureHandoffTokens(base) <= budgetTokens) return base;
708
+ const trimmedRead = (read: { name: string; path: string; content: string; fullOutputPath?: string }) => {
709
+ const content = read.content.trim();
710
+ return content.length > HANDOFF_SUMMARY_HEAD_CHARS
711
+ ? { ...read, content: `[content trimmed, ${content.length} chars total — read Path above for the full file]` }
712
+ : read;
713
+ };
714
+ const candidateAt = (fullCount: number): DependencyOutputContext => ({
715
+ ...base,
716
+ dependencies: base.dependencies.map((dep, index) => (index < fullCount ? dep : { ...dep, budgetTrimmed: true })),
717
+ sharedReads: base.sharedReads.map(trimmedRead),
718
+ });
719
+ for (let fullCount = base.dependencies.length; fullCount >= 0; fullCount--) {
720
+ const candidate = candidateAt(fullCount);
721
+ if (measureHandoffTokens(candidate) <= budgetTokens) return candidate;
722
+ }
723
+ return candidateAt(0);
724
+ }
725
+
726
+ export interface RenderDependencyOutputContextOptions {
727
+ /** Configured budget (runtime.handoffBudgetTokens). Env
728
+ * PI_CREW_HANDOFF_BUDGET_TOKENS beats this value; when neither is set the
729
+ * default is 1800. A resolved value ≤0 or >1_000_000 disables the trim
730
+ * (off-switch). */
731
+ budgetTokens?: number;
732
+ }
733
+
734
+ export function renderDependencyOutputContext(context: DependencyOutputContext, opts: RenderDependencyOutputContextOptions = {}): string {
735
+ const budgetTokens = resolveHandoffBudgetTokens(opts.budgetTokens);
736
+ const effective = applyHandoffBudget(context, budgetTokens);
737
+ return sanitizeFencedBody(buildDependencyOutputBodyParts(effective).join("\n").trim());
579
738
  }
580
739
 
581
740
  export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
@@ -203,6 +203,9 @@ export const HANDOFF_TEMPLATE = [
203
203
  "",
204
204
  "### Follow-ups",
205
205
  "<!-- Any remaining issues or next steps -->",
206
+ "",
207
+ "### Provenance",
208
+ "<!-- Self-report your runtime identity (pre-set in your bash tool env, one line): PI_SESSION_ID, PI_SESSION_FILE, PI_MODEL, PI_PROVIDER, PI_REASONING_LEVEL — e.g. `session=<id> model=<provider>/<model> reasoning=<level>` -->",
206
209
  ].join("\n");
207
210
 
208
211
  export interface ParsedHandoff {