pi-crew 0.9.62 → 0.9.64

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,363 @@
1
+ /**
2
+ * Cell transform: TypeScript cell → sloppy-mode async body run under
3
+ * `with (proxy)` against the persistent namespace.
4
+ *
5
+ * - Types are stripped with esbuild's transform (pi-crew's runtime
6
+ * dependency; Bun.Transpiler is not available here). esbuild does not run
7
+ * dead-code elimination in transform mode, so side-effect-free trailing
8
+ * expressions — exactly what this transform captures as the cell result —
9
+ * survive untouched.
10
+ * - Top-level imports become awaited dynamic imports, since the body is a
11
+ * function body rather than a module. They are rewritten from the ORIGINAL
12
+ * source before type stripping: esbuild elides unused imports, and the
13
+ * reference behaviour binds every imported name into the namespace so it
14
+ * persists across cells. (The pipeline's ImportDeclaration branch is kept
15
+ * for fidelity and as a fallback for any import that survives.)
16
+ * - Top-level declarations become plain assignments, so each binding reaches
17
+ * the namespace at its own statement site. Two behaviours depend on this:
18
+ * a closure must observe later rebinding of a name, and names bound before a
19
+ * cell throws or is cancelled must survive. Copying the bindings out once at
20
+ * the end of the cell delivers neither — that copy never runs on the paths
21
+ * that matter.
22
+ * - A trailing expression statement is captured as the cell result.
23
+ */
24
+
25
+ import type { ClassDeclaration, FunctionDeclaration, ImportDeclaration, Node, Pattern, Program, VariableDeclaration } from "acorn";
26
+ import { parse, tokenizer } from "acorn";
27
+ import { transformSync } from "esbuild";
28
+
29
+ export interface TransformedCell {
30
+ /** Body statements to run inside the async `with` wrapper. */
31
+ body: string;
32
+ /** Top-level names this cell binds into the namespace. */
33
+ declaredNames: string[];
34
+ }
35
+
36
+ export interface TransformOptions {
37
+ /** Identifier the wrapper binds the cell context to. */
38
+ ctxName?: string;
39
+ }
40
+
41
+ // esbuild's transform strips types but never drops side-effect-free trailing
42
+ // expressions (no DCE in transform mode) — the exact thing we capture as the
43
+ // cell result.
44
+ function stripTypes(code: string): string {
45
+ return transformSync(code, { loader: "ts" }).code;
46
+ }
47
+
48
+ // ── top-level import extraction ──────────────────────────────────────────────
49
+ // esbuild elides unused imports during transform, which would silently drop
50
+ // `import { a } from "m"` cells that bind a name without using it in the same
51
+ // cell (names must persist across cells). So imports are rewritten into
52
+ // awaited dynamic imports BEFORE type stripping, using acorn's tokenizer to
53
+ // find them robustly (strings, regexes, templates, and dynamic import() /
54
+ // import.meta never produce a false statement).
55
+
56
+ interface LexToken {
57
+ label: string;
58
+ start: number;
59
+ end: number;
60
+ /** Raw source text (token.value is the parsed value; the raw slice is what
61
+ * the fragment rebuild needs). */
62
+ value: string;
63
+ }
64
+
65
+ interface ImportStatement {
66
+ /** Start of the `import` keyword. */
67
+ start: number;
68
+ /** End of the statement (module specifier, or trailing `;` when present). */
69
+ end: number;
70
+ /** First and last token indices (inclusive) in the global token array. */
71
+ firstToken: number;
72
+ lastToken: number;
73
+ /** Whole-statement `import type …` — erased, binds nothing at runtime. */
74
+ typeOnly: boolean;
75
+ }
76
+
77
+ function lexTopLevel(code: string): LexToken[] {
78
+ const tokens: LexToken[] = [];
79
+ const tok = tokenizer(code, { ecmaVersion: "latest" });
80
+ let current: { type: { label: string }; start: number; end: number };
81
+ while ((current = tok.getToken() as { type: { label: string }; start: number; end: number }).type.label !== "eof") {
82
+ tokens.push({
83
+ label: String(current.type.label),
84
+ start: current.start,
85
+ end: current.end,
86
+ value: code.slice(current.start, current.end),
87
+ });
88
+ }
89
+ return tokens;
90
+ }
91
+
92
+ function findImportStatements(tokens: LexToken[]): ImportStatement[] {
93
+ const found: ImportStatement[] = [];
94
+ for (let i = 0; i < tokens.length; i++) {
95
+ if (tokens[i].label !== "import") continue;
96
+ const prev = i > 0 ? tokens[i - 1].label : null;
97
+ const next = i + 1 < tokens.length ? tokens[i + 1].label : null;
98
+ // Dynamic import(...), import.meta, and property access `x.import` are
99
+ // expressions, not statements.
100
+ if (prev === "." || next === null || next === "(" || next === ".") continue;
101
+
102
+ const j = i + 1;
103
+ let lastToken = -1;
104
+ let typeOnly = false;
105
+ // Whole-statement `import type …` — `type` followed by a string, `{`,
106
+ // or `*` (a default binding named `type` is followed by `from`).
107
+ if (tokens[j].label === "name" && tokens[j].value === "type") {
108
+ const after = j + 1 < tokens.length ? tokens[j + 1].label : null;
109
+ if (after === "string" || after === "{" || after === "*") typeOnly = true;
110
+ }
111
+ if (tokens[j].label === "string") {
112
+ // Side-effect import: `import "mod";`
113
+ lastToken = j;
114
+ } else {
115
+ // Scan for `from` at bracket depth 0, then the module specifier.
116
+ let depth = 0;
117
+ for (let k = j; k < tokens.length; k++) {
118
+ const label = tokens[k].label;
119
+ if (label === "{" || label === "(" || label === "[") depth++;
120
+ else if (label === "}" || label === ")" || label === "]") depth--;
121
+ else if (depth === 0 && label === "name" && tokens[k].value === "from") {
122
+ const spec = k + 1;
123
+ if (spec < tokens.length && tokens[spec].label === "string") lastToken = spec;
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ if (lastToken === -1) continue; // malformed — leave for the pipeline to surface
129
+ // Consume an optional trailing semicolon into the statement.
130
+ const afterString = lastToken + 1;
131
+ if (afterString < tokens.length && tokens[afterString].label === ";") lastToken = afterString;
132
+
133
+ found.push({ start: tokens[i].start, end: tokens[lastToken].end, firstToken: i, lastToken, typeOnly });
134
+ }
135
+ return found;
136
+ }
137
+
138
+ /**
139
+ * Token indices to drop from a named-import fragment for inline `type`
140
+ * specifiers: `import { type X, join } from "m"` → `import { join } from "m"`.
141
+ * The skipped specifier (plus its optional `as` alias) has no runtime binding,
142
+ * exactly like TypeScript's own type-specifier erasure.
143
+ */
144
+ function inlineTypeSkipIndices(tokens: LexToken[], firstToken: number, lastToken: number): Set<number> {
145
+ const skip = new Set<number>();
146
+ let depth = 0;
147
+ for (let k = firstToken + 1; k <= lastToken; k++) {
148
+ const tk = tokens[k];
149
+ if (tk.label === "{") depth++;
150
+ else if (tk.label === "}") depth--;
151
+ if (depth < 1) continue;
152
+ // Only a `type` followed by a binding name is a modifier; `{ type }` and
153
+ // `{ type as x }` import a binding literally named `type`.
154
+ if (tk.label !== "name" || tk.value !== "type") continue;
155
+ const nxt = k + 1 <= lastToken ? tokens[k + 1] : undefined;
156
+ if (nxt?.label !== "name" || nxt.value === "as") continue;
157
+ skip.add(k);
158
+ skip.add(k + 1);
159
+ let m = k + 2;
160
+ // `type X as Y`
161
+ if (m <= lastToken && tokens[m].label === "name" && tokens[m].value === "as") {
162
+ skip.add(m);
163
+ if (m + 1 <= lastToken) skip.add(m + 1);
164
+ m += 2;
165
+ }
166
+ // Drop the separator only when more specifiers follow; a trailing comma
167
+ // after the last kept specifier stays valid.
168
+ if (m <= lastToken && tokens[m].label === ",") {
169
+ const after = m + 1 <= lastToken ? tokens[m + 1] : undefined;
170
+ if (after && after.label !== "}") skip.add(m);
171
+ }
172
+ }
173
+ return skip;
174
+ }
175
+
176
+ function importFragment(code: string, stmt: ImportStatement, tokens: LexToken[], skip: Set<number>): string {
177
+ if (skip.size === 0) return code.slice(stmt.start, stmt.end);
178
+ // Rebuild without the skipped tokens; whitespace/comments are dropped but
179
+ // single spaces keep the fragment parseable.
180
+ const parts: string[] = [];
181
+ for (let k = stmt.firstToken; k <= stmt.lastToken; k++) {
182
+ if (skip.has(k)) continue;
183
+ parts.push(tokens[k].value);
184
+ }
185
+ return parts.join(" ");
186
+ }
187
+
188
+ function rewriteImport(code: string, stmt: ImportStatement, tokens: LexToken[]): { replacement: string; declaredNames: string[] } {
189
+ if (stmt.typeOnly) return { replacement: "", declaredNames: [] };
190
+ const skip = inlineTypeSkipIndices(tokens, stmt.firstToken, stmt.lastToken);
191
+ const fragment = importFragment(code, stmt, tokens, skip);
192
+ let node: ImportDeclaration;
193
+ try {
194
+ const program: Program = parse(fragment, { ecmaVersion: "latest", sourceType: "module" });
195
+ node = program.body[0] as ImportDeclaration;
196
+ } catch {
197
+ // Unparseable — leave the original text for the pipeline to surface.
198
+ return { replacement: code.slice(stmt.start, stmt.end), declaredNames: [] };
199
+ }
200
+ const declaredNames: string[] = [];
201
+ for (const spec of node.specifiers) declaredNames.push(spec.local.name);
202
+ // The block wrapper keeps the assignment from being mistaken for the cell's
203
+ // trailing expression: the trailing-expression capture must not treat an
204
+ // import's dynamic-import assignment as a result.
205
+ return { replacement: `{ ${importReplacement(node)} }`, declaredNames };
206
+ }
207
+
208
+ function collectPatternNames(pattern: Pattern, into: string[]): void {
209
+ switch (pattern.type) {
210
+ case "Identifier":
211
+ into.push(pattern.name);
212
+ break;
213
+ case "ObjectPattern":
214
+ for (const prop of pattern.properties) {
215
+ if (prop.type === "RestElement") collectPatternNames(prop.argument, into);
216
+ else collectPatternNames(prop.value, into);
217
+ }
218
+ break;
219
+ case "ArrayPattern":
220
+ for (const element of pattern.elements) if (element) collectPatternNames(element, into);
221
+ break;
222
+ case "AssignmentPattern":
223
+ collectPatternNames(pattern.left, into);
224
+ break;
225
+ case "RestElement":
226
+ collectPatternNames(pattern.argument, into);
227
+ break;
228
+ default:
229
+ break;
230
+ }
231
+ }
232
+
233
+ function importReplacement(node: ImportDeclaration): string {
234
+ const moduleText = JSON.stringify(String(node.source.value));
235
+ const namespaceSpecifier = node.specifiers.find((s) => s.type === "ImportNamespaceSpecifier");
236
+ const defaultSpecifier = node.specifiers.find((s) => s.type === "ImportDefaultSpecifier");
237
+ const namedSpecifiers = node.specifiers.filter((s) => s.type === "ImportSpecifier");
238
+
239
+ // Assignments, not declarations: imported bindings must land in the
240
+ // namespace so they persist across cells like any other name.
241
+ const parts: string[] = [];
242
+ if (namespaceSpecifier) parts.push(`${namespaceSpecifier.local.name} = await import(${moduleText});`); // LAZY: codegen emits guest-cell import syntax, not a pi-crew dynamic import
243
+ const destructured: string[] = [];
244
+ if (defaultSpecifier) destructured.push(`default: ${defaultSpecifier.local.name}`);
245
+ for (const spec of namedSpecifiers) {
246
+ const imported = spec.imported.type === "Identifier" ? spec.imported.name : String(spec.imported.value);
247
+ destructured.push(imported === spec.local.name ? imported : `${JSON.stringify(imported)}: ${spec.local.name}`);
248
+ }
249
+ if (destructured.length > 0) parts.push(`({ ${destructured.join(", ")} } = await import(${moduleText}));`); // LAZY: codegen emits guest-cell import syntax
250
+ if (parts.length === 0) parts.push(`await import(${moduleText});`); // LAZY: codegen emits guest-cell import syntax
251
+ return parts.join(" ");
252
+ }
253
+
254
+ /**
255
+ * Rewrite `let/const/var` into assignments so each binding reaches the
256
+ * namespace as it executes. Patterns keep their shape; object patterns need
257
+ * parentheses to stay expressions.
258
+ */
259
+ function variableReplacement(decl: VariableDeclaration, source: string): string {
260
+ const statements: string[] = [];
261
+ for (const declarator of decl.declarations) {
262
+ const target = source.slice(declarator.id.start, declarator.id.end);
263
+ if (!declarator.init) {
264
+ // `let x;` — bind the name so later reads resolve.
265
+ statements.push(`${target} = undefined;`);
266
+ continue;
267
+ }
268
+ const init = source.slice(declarator.init.start, declarator.init.end);
269
+ statements.push(declarator.id.type === "ObjectPattern" ? `(${target} = ${init});` : `${target} = ${init};`);
270
+ }
271
+ return statements.join(" ");
272
+ }
273
+
274
+ export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
275
+ const ctxName = options.ctxName ?? "__ctx";
276
+
277
+ // Top-level imports are rewritten into awaited dynamic imports BEFORE type
278
+ // stripping: esbuild elides unused imports, and the reference behaviour
279
+ // binds every imported name into the namespace so it persists across cells.
280
+ let importDeclared: string[] = [];
281
+ let rewritten = code;
282
+ try {
283
+ const tokens = lexTopLevel(code);
284
+ const imports = findImportStatements(tokens);
285
+ if (imports.length > 0) {
286
+ importDeclared = [];
287
+ const pieces: string[] = [];
288
+ let cursor = 0;
289
+ for (const stmt of imports) {
290
+ pieces.push(rewritten.slice(cursor, stmt.start));
291
+ const rewrite = rewriteImport(code, stmt, tokens);
292
+ importDeclared.push(...rewrite.declaredNames);
293
+ pieces.push(rewrite.replacement);
294
+ cursor = stmt.end;
295
+ }
296
+ pieces.push(rewritten.slice(cursor));
297
+ rewritten = pieces.join("");
298
+ }
299
+ } catch {
300
+ // Tokenizing the original source failed (genuine syntax error). Skip
301
+ // the pre-rewrite; type stripping below surfaces the error.
302
+ }
303
+
304
+ const js = stripTypes(rewritten);
305
+ const program: Program = parse(js, { ecmaVersion: "latest", sourceType: "module", allowAwaitOutsideFunction: true });
306
+
307
+ const declaredNames: string[] = [];
308
+ const replacements: { start: number; end: number; text: string }[] = [];
309
+ const topLevel = program.body;
310
+
311
+ for (const node of topLevel) {
312
+ switch (node.type) {
313
+ case "ImportDeclaration": {
314
+ const decl = node as ImportDeclaration;
315
+ for (const spec of decl.specifiers) declaredNames.push(spec.local.name);
316
+ replacements.push({ start: decl.start, end: decl.end, text: importReplacement(decl) });
317
+ break;
318
+ }
319
+ case "ExportNamedDeclaration":
320
+ case "ExportDefaultDeclaration":
321
+ case "ExportAllDeclaration":
322
+ throw new SyntaxError("export statements are not supported in cells");
323
+ case "VariableDeclaration": {
324
+ const decl = node as VariableDeclaration;
325
+ for (const declarator of decl.declarations) collectPatternNames(declarator.id, declaredNames);
326
+ replacements.push({ start: decl.start, end: decl.end, text: variableReplacement(decl, js) });
327
+ break;
328
+ }
329
+ case "FunctionDeclaration":
330
+ case "ClassDeclaration": {
331
+ const decl = node as FunctionDeclaration | ClassDeclaration;
332
+ if (!decl.id) break;
333
+ declaredNames.push(decl.id.name);
334
+ // A named function/class expression keeps self-reference (recursion)
335
+ // while making the binding proxy-backed and failure-surviving.
336
+ const sourceText = js.slice(decl.start, decl.end);
337
+ replacements.push({ start: decl.start, end: decl.end, text: `${decl.id.name} = ${sourceText};` });
338
+ break;
339
+ }
340
+ default:
341
+ break;
342
+ }
343
+ }
344
+
345
+ // Capture a trailing expression statement as the cell result.
346
+ const last = topLevel[topLevel.length - 1] as Node | undefined;
347
+ if (last && last.type === "ExpressionStatement") {
348
+ const expression = (last as unknown as { expression: Node }).expression;
349
+ const expressionText = js.slice(expression.start, expression.end);
350
+ replacements.push({ start: last.start, end: last.end, text: `${ctxName}.setResult((${expressionText}));` });
351
+ }
352
+
353
+ replacements.sort((a, b) => a.start - b.start);
354
+ let body = "";
355
+ let cursor = 0;
356
+ for (const replacement of replacements) {
357
+ body += js.slice(cursor, replacement.start) + replacement.text;
358
+ cursor = replacement.end;
359
+ }
360
+ body += js.slice(cursor);
361
+
362
+ return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
363
+ }
@@ -259,6 +259,51 @@ export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput
259
259
  * UI event-bus handle (may be undefined).
260
260
  * @returns The branch execution result.
261
261
  */
262
+ // Quick Win 11 (Pattern 11 — error-as-data contract): the per-attempt outcome
263
+ // assembly, extracted as PURE functions so the precedence contract is directly
264
+ // unit-testable. Logic is identical to the former inline blocks; runChildProcessTask
265
+ // calls these instead of inlining. E008 (modelExhausted, post-loop) stays in the
266
+ // caller — putting it here would feed isRetryableModelFailure per-attempt and
267
+ // change the retry chain (MINOR-3).
268
+ //
269
+ // Precedence: evidenceStatus = cancelled > failed (error || non-zero exit) >
270
+ // completed. error = childResult.error > non-zero-exit message, THEN E007
271
+ // (timedOut) overrides unconditionally, THEN 429-detection only when !error.
272
+
273
+ export type AttemptEvidenceStatus = "cancelled" | "failed" | "completed";
274
+
275
+ export function evidenceStatusFor(childResult: ChildPiRunResult): AttemptEvidenceStatus {
276
+ return childResult.exitStatus?.cancelled
277
+ ? "cancelled"
278
+ : childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
279
+ ? "failed"
280
+ : "completed";
281
+ }
282
+
283
+ export function attemptErrorFor(
284
+ childResult: ChildPiRunResult,
285
+ parsedOutput: ParsedPiJsonOutput | undefined,
286
+ taskId: string,
287
+ ): string | undefined {
288
+ let err: string | undefined =
289
+ childResult.error ||
290
+ (childResult.exitCode && childResult.exitCode !== 0
291
+ ? childResult.stderr || `Child Pi exited with ${childResult.exitCode}`
292
+ : undefined);
293
+ // E1/E7: a child timeout surfaces a structured CrewError (E007) — unconditional
294
+ // override of any hard error.
295
+ if (childResult.exitStatus?.timedOut) {
296
+ err = errors.childTimeout({ taskId, stderr: childResult.stderr }).message;
297
+ }
298
+ // 429/rate-limit: only when no error was set above AND the transcript carries
299
+ // only retryable model-failure messages with no real output.
300
+ if (!err && parsedOutput) {
301
+ const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
302
+ if (rateLimitErr) err = rateLimitErr;
303
+ }
304
+ return err;
305
+ }
306
+
262
307
  export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<TaskExecutionResult> {
263
308
  const input = ctx.input;
264
309
  const manifest: TeamRunManifest = ctx.manifest;
@@ -499,6 +544,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
499
544
  runId: manifest.runId,
500
545
  agentId: task.id,
501
546
  artifactsRoot: manifest.artifactsRoot,
547
+ attempt: i,
502
548
  steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
503
549
  onSpawn: (pid) => {
504
550
  try {
@@ -613,11 +659,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
613
659
  input.signal.removeEventListener("abort", externalAbortListener);
614
660
  }
615
661
  }
616
- const evidenceStatus = childResult.exitStatus?.cancelled
617
- ? "cancelled"
618
- : childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
619
- ? "failed"
620
- : "completed";
662
+ const evidenceStatus = evidenceStatusFor(childResult);
621
663
  terminalEvidence = [
622
664
  ...terminalEvidence,
623
665
  {
@@ -668,32 +710,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
668
710
  parsedOutput = parsePiJsonOutput(transcriptText);
669
711
  rawFinalText = childResult.rawFinalText;
670
712
  intermediateFindings = childResult.intermediateFindings;
671
- error =
672
- childResult.error ||
673
- (childResult.exitCode && childResult.exitCode !== 0
674
- ? childResult.stderr || `Child Pi exited with ${childResult.exitCode}`
675
- : undefined);
676
- // E1/E7 (Round 15): when the child timed out, surface a structured
677
- // CrewError (E007) so users get a code + actionable help hint instead
678
- // of a bare 'no new output for N ms'. We keep .message as the task error.
679
- if (childResult.exitStatus?.timedOut) {
680
- error = errors.childTimeout({
681
- taskId: task.id,
682
- stderr: childResult.stderr,
683
- }).message;
684
- }
685
- // 429/rate-limit fix (PI_CREW_TOOLING_429_NOTE.md): a worker can exit
686
- // code 0 with NO hard error, but the transcript is full of
687
- // `message_end` events with `errorMessage: "429 ... overloaded"` and
688
- // empty content. The model never produced a tool call, so the worker
689
- // "completed" without doing anything. Detect this: if no error was set
690
- // above AND the parsed output carries a retryable model-failure message
691
- // AND there is no real output text, surface it as an error so the
692
- // model-fallback chain can retry on another model.
693
- if (!error && parsedOutput) {
694
- const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
695
- if (rateLimitErr) error = rateLimitErr;
696
- }
713
+ error = attemptErrorFor(childResult, parsedOutput, task.id);
697
714
  persistHeartbeat(true);
698
715
  persistChildProgress({ type: "attempt_finished" }, true);
699
716
  const attempt: ModelAttemptSummary = {