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.
@@ -38,6 +38,7 @@ import { type FatalFsCause, failureCauseForAttempt } from "../../utils/fs-errno.
38
38
  import { logInternalError } from "../../utils/internal-error.ts";
39
39
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
40
40
  import type { ChildPiLifecycleEvent, ChildPiRunResult } from "../child-pi/child-pi.ts";
41
+ import type { SessionRecoveryInfo } from "../child-pi/session-recovery.ts";
41
42
  import {
42
43
  appendCrewAgentEventBuffered,
43
44
  appendCrewAgentOutputBuffered,
@@ -398,7 +399,11 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
398
399
  const manifest: TeamRunManifest = ctx.manifest;
399
400
  let task = ctx.task;
400
401
  let tasks = ctx.tasks;
401
- const prompt = ctx.prompt;
402
+ // R3-1: the child-pi worker's USER message is the dynamic suffix — the
403
+ // run-static header rides --append-system-prompt (compaction-safe).
404
+ // Fallback to the full prompt keeps hand-built contexts (tests, custom
405
+ // callers that skip pre-execution) on the old single-channel behavior.
406
+ const userPrompt = ctx.userPrompt ?? ctx.prompt;
402
407
  const skillPaths = ctx.skillPaths;
403
408
  const collectedJsonEvents = ctx.collectedJsonEvents;
404
409
  const streamBridge: StreamBridgeHandle | undefined = ctx.streamBridge;
@@ -409,6 +414,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
409
414
  let modelAttempts: ModelAttemptSummary[] | undefined;
410
415
  let parsedOutput: ParsedPiJsonOutput | undefined;
411
416
  let rawFinalText: string | undefined;
417
+ // W2 (P1-1): last attempt's session-recovery provenance (thread-level,
418
+ // mirrors rawFinalText — escapes the attempt loop's block scope).
419
+ let recoveredFromSession: SessionRecoveryInfo | undefined;
412
420
  let intermediateFindings: string | undefined;
413
421
  let finalStdout = "";
414
422
  let transcriptPath: string | undefined;
@@ -652,7 +660,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
652
660
  try {
653
661
  childResult = await runWorker({
654
662
  cwd: task.cwd,
655
- task: prompt,
663
+ task: userPrompt,
664
+ systemPromptAppend: ctx.systemPromptAppend,
665
+ // R3-19/D5: thread the runtime.hermeticWorkers config gate to the
666
+ // worker argv (--no-extensions). Env PI_CREW_HERMETIC_WORKERS still
667
+ // overrides either way (resolveHermeticWorkers).
668
+ hermeticWorkers: input.runtimeConfig?.hermeticWorkers,
656
669
  agent: input.agent,
657
670
  model,
658
671
  signal: timeoutController.signal,
@@ -665,6 +678,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
665
678
  parentContext: input.parentContext,
666
679
  excludeContextBash: input.runtimeConfig?.excludeContextBash,
667
680
  sessionId: manifest.sessionId,
681
+ // W2 (P1-1): thread the runtime.sessionRecovery flag so the config
682
+ // gate reaches the crash-path tail-replay (env/default still work
683
+ // without this — resolveSessionRecoveryEnabled precedence).
684
+ sessionRecovery: input.runtimeConfig?.sessionRecovery,
668
685
  role: task.role,
669
686
  thinkingOverride: input.teamRoleThinking,
670
687
  runId: manifest.runId,
@@ -997,6 +1014,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
997
1014
  const transcriptText = tailReadWithLineSnap(transcriptPath, MAX_TRANSCRIPT_PARSE_BYTES, childResult.stdout);
998
1015
  parsedOutput = parsePiJsonOutput(transcriptText);
999
1016
  rawFinalText = childResult.rawFinalText;
1017
+ // W2 (P1-1): keep the most recent recovery info (crash-path only —
1018
+ // undefined on clean exits, so this stays dormant in the happy path).
1019
+ recoveredFromSession = childResult.recoveredFromSession ?? recoveredFromSession;
1000
1020
  intermediateFindings = childResult.intermediateFindings;
1001
1021
  error = attemptErrorFor(childResult, parsedOutput, task.id);
1002
1022
  // bug-026 sub-issue B: classify fatal fs errnos (ENOSPC/EDQUOT/EMFILE/
@@ -1121,6 +1141,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1121
1141
  const resultCandidates: ReadonlyArray<{ source: ResultSource; text: string | undefined }> = [
1122
1142
  { source: "rawFinalText", text: cleanResultText(rawFinalText) },
1123
1143
  { source: "finalText", text: cleanResultText(parsedOutput?.finalText) },
1144
+ // W2 (P1-1): crash-path session tail-recovery. Ranks BELOW live captures
1145
+ // (raw/finalText — the stream is authoritative when it survived) but
1146
+ // ABOVE stdout/stderr noise: on signal death (exitCode null) the live
1147
+ // captures are empty and this is the recovered last complete assistant
1148
+ // message from the worker's session JSONL.
1149
+ { source: "session", text: cleanResultText(recoveredFromSession?.text) },
1124
1150
  { source: "stdout", text: cleanResultText(finalStdout) },
1125
1151
  { source: "stderr", text: cleanResultText(finalStderr) },
1126
1152
  { source: "findings", text: cleanResultText(intermediateFindings) },
@@ -1142,6 +1168,18 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1142
1168
  "(no output)",
1143
1169
  producer: task.id,
1144
1170
  });
1171
+ // W2 (P1-1): serialize the recovery provenance as a result sidecar — the
1172
+ // recovered text itself flows through the "session" candidate above; this
1173
+ // records WHERE it came from (session id/file, timestamp, stopReason) so
1174
+ // crash-path recovery is auditable after the fact.
1175
+ if (recoveredFromSession) {
1176
+ writeArtifact(manifest.artifactsRoot, {
1177
+ kind: "result",
1178
+ relativePath: `results/${task.id}.session-recovery.json`,
1179
+ content: JSON.stringify(recoveredFromSession, null, "\t"),
1180
+ producer: task.id,
1181
+ });
1182
+ }
1145
1183
  const logArtifact = writeArtifact(manifest.artifactsRoot, {
1146
1184
  kind: "log",
1147
1185
  relativePath: `logs/${task.id}.log`,
@@ -86,16 +86,18 @@ export interface TaskExecutionResult {
86
86
  terminalEvidence: OperationTerminalEvidence[];
87
87
  startupEvidence: import("../heartbeat/worker-startup.ts").WorkerStartupEvidence;
88
88
  /** Which child-executor fallback branch produced the result artifact
89
- * content (rawFinalText → finalText → stdout → stderr → findings →
89
+ * content (rawFinalText → finalText → session → stdout → stderr → findings →
90
90
  * "(no output)"). Finding #2 (2026-09-29 battery): "stderr"/"none" mean
91
91
  * every authoritative worker output source was empty — finalizeTaskResult
92
92
  * fails those DETERMINISTICALLY; the stderr-noise classifier stays a
93
- * defense layer, never the verdict oracle. */
93
+ * defense layer, never the verdict oracle. "session" (W2/P1-1) = the text
94
+ * was tail-recovered from the worker's session JSONL on a signal-death
95
+ * crash path — authoritative recovered output, not a failure source. */
94
96
  resultSource?: ResultSource;
95
97
  }
96
98
 
97
99
  /** Result-artifact provenance tags (see {@link TaskExecutionResult.resultSource}). */
98
- export type ResultSource = "rawFinalText" | "finalText" | "stdout" | "stderr" | "findings" | "none";
100
+ export type ResultSource = "rawFinalText" | "finalText" | "session" | "stdout" | "stderr" | "findings" | "none";
99
101
 
100
102
  /** Final persisted task state returned by finalizeTaskResult. */
101
103
  export interface FinalTaskResult {
@@ -69,6 +69,17 @@ export interface TaskExecutionContext {
69
69
  skillNames: string[] | undefined;
70
70
  skillPaths: string[] | undefined;
71
71
  prompt: string;
72
+ /** R3-1: the USER-message half of the rendered prompt (dynamicSuffix) —
73
+ * what the worker's task.md actually carries when the run-static header
74
+ * moves to the --append-system-prompt channel. `prompt` stays the FULL
75
+ * rendered prompt (stablePrefix + dynamicSuffix) for artifacts and the
76
+ * live-session path. Optional so hand-built contexts (tests, custom
77
+ * callers) keep the old single-channel behavior via the ?? fallback. */
78
+ userPrompt?: string;
79
+ /** R3-1: run-static worker header (stablePrefix) for the append-system-prompt
80
+ * channel — threaded to buildPiWorkerArgs via ChildPiRunInput. Undefined on
81
+ * hand-built contexts → single-channel legacy behavior. */
82
+ systemPromptAppend?: string;
72
83
  promptArtifact: ArtifactDescriptor;
73
84
  /** G18: prompt-breakdown artifact descriptor (undefined when PI_CREW_PROMPT_BREAKDOWN off). */
74
85
  breakdownArtifact?: ArtifactDescriptor;
@@ -141,7 +152,14 @@ export async function prepareTaskExecutionContext(
141
152
  // caller provided one) into the dep-context collection — cache hits reuse
142
153
  // the closeout's reads byte-identically; undefined keeps uncached behavior.
143
154
  const dependencyContext = collectDependencyOutputContext(manifest, input.tasks, input.task, input.step, input.resultReadCache);
144
- const dependencyContextText = input.dependencyContextText ?? renderDependencyOutputContext(dependencyContext);
155
+ // Handoff budget: caps the dynamic.dependencyContext layer at an est-token
156
+ // (chars/4) budget (env PI_CREW_HANDOFF_BUDGET_TOKENS > runtime config >
157
+ // default 1800). IMPORTANT: only the RENDER path is budgeted — an explicit
158
+ // input.dependencyContextText override (leader-provided text) passes through
159
+ // UNCHANGED.
160
+ const dependencyContextText =
161
+ input.dependencyContextText ??
162
+ renderDependencyOutputContext(dependencyContext, { budgetTokens: input.runtimeConfig?.handoffBudgetTokens });
145
163
  let task: TeamTaskState = {
146
164
  ...input.task,
147
165
  cwd: workspace.cwd,
@@ -435,6 +453,11 @@ export async function prepareTaskExecutionContext(
435
453
  collectedJsonEvents,
436
454
  streamBridge,
437
455
  startupEvidence,
456
+ // R3-1: channel split for child-pi workers — user message = dynamic
457
+ // suffix; run-static header = system-prompt append channel (compaction
458
+ // summarizes the user-message span, compaction.md:150-160).
459
+ userPrompt: promptResult.dynamicSuffix,
460
+ systemPromptAppend: promptResult.stablePrefix,
438
461
  },
439
462
  };
440
463
  }
@@ -275,11 +275,14 @@ export interface RenderedTaskPrompt {
275
275
  /** Full rendered prompt (stablePrefix + dynamicSuffix). */
276
276
  full: string;
277
277
  /**
278
- * SR-02 phase 1 (2026-09-23): per-section char counts of the USER prompt,
278
+ * SR-02 phase 1 (2026-09-23): per-section char counts of the worker prompt,
279
279
  * keyed by section name — the token-breakdown instrumentation. Populated
280
280
  * only when PI_CREW_PROMPT_BREAKDOWN=1 (off by default; zero cost when off:
281
281
  * the sections object is built lazily). Pre-execution adds the SYSTEM-side
282
282
  * pieces (agent definition, skills) and writes the JSON artifact.
283
+ * R3-1 note: stable.* sections ride the --append-system-prompt channel;
284
+ * total.userPrompt counts the user message (dynamic suffix) only, while
285
+ * total.systemAppend counts the system-channel header.
283
286
  */
284
287
  sections?: Record<string, number>;
285
288
  }
@@ -424,6 +427,8 @@ export async function renderTaskPrompt(
424
427
  const full = [stablePrefix, "", dynamicSuffix].join("\n");
425
428
  const sections: Record<string, number> | undefined = promptBreakdownEnabled()
426
429
  ? {
430
+ // R3-1: the stable.* sections now ride the --append-system-prompt
431
+ // channel; the worker's user message is the dynamic suffix only.
427
432
  "stable.runtimeHeader": headerBlock.length,
428
433
  "stable.protocol": protocolBlock.length,
429
434
  "stable.roleInstructions": roleInstructions.length,
@@ -440,7 +445,10 @@ export async function renderTaskPrompt(
440
445
  "dynamic.memory": memoryBlock.length,
441
446
  "dynamic.outputSchema": outputSchemaBlock.length,
442
447
  "dynamic.taskAndHandoff": taskAndHandoff.length,
443
- "total.userPrompt": full.length,
448
+ // R3-1: channel split — the user message carries ONLY the dynamic
449
+ // suffix; the stable prefix went to the system-prompt append channel.
450
+ "total.systemAppend": stablePrefix.length,
451
+ "total.userPrompt": dynamicSuffix.length,
444
452
  }
445
453
  : undefined;
446
454
  return { stablePrefix, dynamicSuffix, full, sections };
@@ -75,6 +75,18 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
75
75
  graceTurns: Type.Optional(Type.Integer({ minimum: 1 })),
76
76
  taskTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
77
77
  inheritContext: Type.Optional(Type.Boolean({ sensitive: true })),
78
+ // W2 (session-file recovery): when a worker dies without a final assistant
79
+ // event (exitCode null / killed), tail-replay its session JSONL and attach
80
+ // the last complete assistant record to the run result. Default ON; env
81
+ // PI_CREW_SESSION_RECOVERY overrides. Kill switch, not a secret — no
82
+ // sensitive mark (same policy as surface: picks behavior, not authority).
83
+ sessionRecovery: Type.Optional(Type.Boolean()),
84
+ // R3-19/D5 reversal: hermetic worker spawns (--no-extensions on worker
85
+ // argv; explicit -e prompt-runtime + --skill flags unaffected). Default
86
+ // TRUE — off-switch for operators who want the old ambient-stack parity.
87
+ // Env PI_CREW_HERMETIC_WORKERS overrides either way. Behavior pick, not
88
+ // authority — no sensitive mark.
89
+ hermeticWorkers: Type.Optional(Type.Boolean()),
78
90
  promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
79
91
  groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
80
92
  groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -94,6 +106,10 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
94
106
  ),
95
107
  ),
96
108
  excludeContextBash: Type.Optional(Type.Boolean()),
109
+ // Handoff budget: est-token (chars/4) cap on the dynamic.dependencyContext
110
+ // prompt layer. Matches parseHandoffBudgetTokens in config-validation.ts
111
+ // (out-of-range values are dropped → render default 1800).
112
+ handoffBudgetTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000_000 })),
97
113
  agentExtensions: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { sensitive: true })),
98
114
  // Mux-surface policy (spec v0.7 §8.2.4): no sensitive marks — surface
99
115
  // picks where worker processes live, not what they may do.
@@ -118,6 +134,15 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
118
134
  ),
119
135
  ),
120
136
  modelFallback: Type.Optional(PiTeamsModelFallbackConfigSchema),
137
+ // W7 (P2-3) EXPERIMENTAL: worker transport selector. Absent = "stdio"
138
+ // (production child-pi path, behavior unchanged). "rpc" selects the
139
+ // prototype RPC frame-client transport (src/runtime/rpc/) — currently
140
+ // the run-worker seam returns a structured not-implemented result
141
+ // instead of spawning. Live gate is env PI_CREW_WORKER_TRANSPORT;
142
+ // config→seam plumbing is the integration-phase follow-up (needs a
143
+ // parseRuntimeConfig line in config-validation.ts). Not sensitive —
144
+ // picks a transport, not authority.
145
+ workerTransport: Type.Optional(Type.Union([Type.Literal("stdio"), Type.Literal("rpc")])),
121
146
  },
122
147
  { additionalProperties: false },
123
148
  );
@@ -280,6 +280,14 @@ const sharedFields = {
280
280
  description: "Mark certain bash commands as excludeFromContext to reduce context tokens (default: false).",
281
281
  }),
282
282
  ),
283
+ handoffBudgetTokens: Type.Optional(
284
+ Type.Integer({
285
+ minimum: 1,
286
+ maximum: 1_000_000,
287
+ description:
288
+ "Est-token (chars/4) budget cap on the dependency-context layer of worker prompts (default 1800). When exceeded, later dependencies are trimmed to summary head + artifact pointer. Out-of-range values are ignored.",
289
+ }),
290
+ ),
283
291
  budgetTotal: Type.Optional(
284
292
  // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
285
293
  // 0 accepted as "unset/disabled" (models emit 0 for off); still rejects 1-999
@@ -596,6 +604,8 @@ export interface TeamToolParamsValue {
596
604
  b?: number;
597
605
  /** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
598
606
  excludeContextBash?: boolean;
607
+ /** Est-token (chars/4) budget cap on the dependency-context layer of worker prompts (default 1800). Later dependencies get trimmed to summary head + artifact pointer when exceeded. */
608
+ handoffBudgetTokens?: number;
599
609
  /** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
600
610
  budgetTotal?: number;
601
611
  /** When true, skip budget enforcement entirely (explicit opt-out). */
@@ -1,5 +1,21 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ /**
4
+ * Host-compatibility shims for the `ExtensionContext["ui"]` surface.
5
+ *
6
+ * RETENTION POLICY (W4 2026-10-03): these typeof-based capability guards are
7
+ * deliberate no-ops, NOT version guards, and must be kept. pi-crew declares
8
+ * `"@earendil-works/pi-coding-agent": "*"` and `"@earendil-works/pi-tui": "*"`
9
+ * as optional peerDependencies with no minimum-version floor
10
+ * (package.json peerDependencies/peerDependenciesMeta), so host installs older
11
+ * than any SDK floor we verify against must keep working — retiring these
12
+ * shims would crash hosts whose UI predates
13
+ * `setWidget`/`setFooter`/`custom`/`setStatus`/`setWorkingIndicator`.
14
+ * Verified live: each guard is a pure feature check; there is no version
15
+ * constant or gate in this file. New host UI APIs should be added here
16
+ * (feature-detected), not called directly from src/.
17
+ */
18
+
3
19
  export interface WorkingIndicatorOptions {
4
20
  frames?: string[];
5
21
  intervalMs?: number;
@@ -9,7 +25,6 @@ type UiContext = Pick<ExtensionContext, "ui">;
9
25
  type ExtensionUi = ExtensionContext["ui"];
10
26
  type WidgetContent = string[] | ((tui: unknown, theme: unknown) => unknown);
11
27
  type WidgetOptions = Parameters<ExtensionUi["setWidget"]>[2];
12
- type WidgetOptionsWithPersist = WidgetOptions & { persist?: boolean };
13
28
 
14
29
  type CustomOptions = Parameters<ExtensionUi["custom"]>[1];
15
30
 
@@ -38,16 +53,10 @@ export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOp
38
53
 
39
54
  /** Install or update an extension widget slot.
40
55
  * No-op when the host UI predates the `setWidget` API. */
41
- export function setExtensionWidget(
42
- ctx: UiContext,
43
- key: string,
44
- content: WidgetContent | undefined,
45
- options?: WidgetOptionsWithPersist,
46
- ): void {
47
- const { persist: _persist, ...widgetOptions } = options ?? {};
56
+ export function setExtensionWidget(ctx: UiContext, key: string, content: WidgetContent | undefined, options?: WidgetOptions): void {
48
57
  const record = maybeRecord(ctx.ui);
49
58
  const fn = record?.setWidget;
50
- if (typeof fn === "function") fn.call(ctx.ui, key, content as never, widgetOptions as WidgetOptions);
59
+ if (typeof fn === "function") fn.call(ctx.ui, key, content as never, options);
51
60
  }
52
61
 
53
62
  /**
@@ -522,7 +522,6 @@ export function updateCrewWidget(
522
522
  const model = state.model;
523
523
  setExtensionWidget(ctx, WIDGET_KEY, ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never, {
524
524
  placement: piPlacement,
525
- persist: true,
526
525
  });
527
526
  state.lastVisibility = "visible";
528
527
  state.lastPlacement = placement;