@tea-agent/loop-agent 0.16.25 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +118 -16
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +85 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/executors/shell-write-guard.js +64 -2
  16. package/dist/shared/operator/capabilities.js +255 -0
  17. package/dist/shared/operator/envelope.js +59 -0
  18. package/dist/shared/operator/index.js +4 -0
  19. package/dist/shared/operator/registry.js +38 -0
  20. package/dist/shared/operator/types.js +5 -0
  21. package/dist/task/contract/adopt.js +166 -0
  22. package/dist/task/contract/apply.js +326 -0
  23. package/dist/task/contract/canonicalize.js +60 -0
  24. package/dist/task/contract/constants.js +29 -0
  25. package/dist/task/contract/diff.js +177 -0
  26. package/dist/task/contract/hash.js +42 -0
  27. package/dist/task/contract/import-revision.js +96 -0
  28. package/dist/task/contract/index.js +17 -0
  29. package/dist/task/contract/journal.js +155 -0
  30. package/dist/task/contract/lock.js +153 -0
  31. package/dist/task/contract/observe.js +296 -0
  32. package/dist/task/contract/paths.js +19 -0
  33. package/dist/task/contract/project.js +170 -0
  34. package/dist/task/contract/recover.js +312 -0
  35. package/dist/task/contract/request-ledger.js +37 -0
  36. package/dist/task/contract/schema.js +151 -0
  37. package/dist/task/contract/transaction.js +160 -0
  38. package/dist/task/contract/types.js +1 -0
  39. package/dist/task/contract/validate-draft.js +106 -0
  40. package/dist/task/index.js +3 -0
  41. package/dist/task/operator/capabilities.js +6 -0
  42. package/dist/task/operator/envelope.js +2 -0
  43. package/dist/task/operator/index.js +5 -0
  44. package/dist/task/operator/registry.js +2 -0
  45. package/dist/task/operator/types.js +1 -0
  46. package/dist/task/runtime.js +5 -1
  47. package/dist/task/source-references.js +7 -0
  48. package/dist/worker/cli.js +150 -32
  49. package/dist/worker/console/app-data.js +185 -0
  50. package/dist/worker/console/dag-confirmation.js +313 -0
  51. package/dist/worker/console/doctor.js +169 -0
  52. package/dist/worker/console/draft-store.js +80 -0
  53. package/dist/worker/console/index.js +15 -0
  54. package/dist/worker/console/interview/assessment.js +67 -0
  55. package/dist/worker/console/interview/session.js +100 -0
  56. package/dist/worker/console/interview/tools.js +109 -0
  57. package/dist/worker/console/loopback.js +16 -0
  58. package/dist/worker/console/observe-health-match.js +174 -0
  59. package/dist/worker/console/observe-link.js +33 -0
  60. package/dist/worker/console/operation-runner.js +166 -0
  61. package/dist/worker/console/operation-sse.js +158 -0
  62. package/dist/worker/console/operation-store.js +147 -0
  63. package/dist/worker/console/operator-actions.js +769 -0
  64. package/dist/worker/console/pi-readiness.js +94 -0
  65. package/dist/worker/console/recovery-cta.js +133 -0
  66. package/dist/worker/console/repo-fingerprint.js +29 -0
  67. package/dist/worker/console/resource-loader.js +95 -0
  68. package/dist/worker/console/routes.js +368 -0
  69. package/dist/worker/console/security.js +126 -0
  70. package/dist/worker/console/server.js +149 -0
  71. package/dist/worker/console/sibling-controller.js +28 -0
  72. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  73. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  74. package/dist/worker/console/static/index.html +13 -0
  75. package/dist/worker/console/vite.config.js +27 -0
  76. package/dist/worker/delivery/git-transaction.js +43 -8
  77. package/dist/worker/observe/health.js +57 -0
  78. package/dist/worker/observe/paths.js +81 -0
  79. package/dist/worker/observe/routes.js +142 -27
  80. package/dist/worker/observe/spec-evidence.js +84 -0
  81. package/dist/worker/observe/static/api.js +23 -0
  82. package/dist/worker/observe/static/state.js +26 -0
  83. package/dist/worker/observe/static/styles.css +10 -0
  84. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  85. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  86. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  87. package/dist/workflows/dag/frontend-repair.js +1 -10
  88. package/dist/workflows/dag/init-hybrid.js +372 -115
  89. package/dist/workflows/dag/node-execution.js +57 -6
  90. package/dist/workflows/dag/project-governance-context.js +508 -0
  91. package/dist/workflows/dag/prompt.js +46 -1
  92. package/dist/workflows/dag/retry-policy.js +16 -1
  93. package/dist/workflows/dag/runner.js +9 -0
  94. package/dist/workflows/dag/skill-snapshot.js +1 -0
  95. package/dist/workflows/dag/task-contract-binding.js +138 -0
  96. package/dist/workflows/dag/types.js +84 -10
  97. package/dist/workflows/dag/validate.js +53 -7
  98. package/docs/README.md +2 -0
  99. package/docs/architecture/evolution.md +2 -0
  100. package/docs/architecture/system-overview.md +6 -0
  101. package/docs/architecture/worker-and-feature.md +7 -0
  102. package/docs/templates/agent-dag.schema.json +64 -2
  103. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  104. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  105. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  106. package/docs/templates/backend-test-dag.json +26 -154
  107. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  108. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  109. package/package.json +8 -2
  110. package/skills/agent-worker/SKILL.md +1 -0
  111. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  112. package/skills/frontend-design-review/SKILL.md +25 -16
  113. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  114. package/skills/loop-agent/references/command-reference.md +48 -1
  115. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
- import { writeTextArtifactFile } from "../infrastructure/harness/artifact-store.js";
2
+ import { createHash } from "node:crypto";
3
+ import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
3
4
  import { executePiStep, } from "./pi-executor.js";
4
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
5
- import { readGitStatusPorcelain, runPostRunWriteGuard, } from "./shell-write-guard.js";
6
+ import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
6
7
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
7
8
  export const DAG_PI_WRITE_TOOLS = [
8
9
  "read",
@@ -240,12 +241,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
240
241
  };
241
242
  }
242
243
  let beforeStatus;
244
+ let beforePathFingerprints;
243
245
  if (isWriteTask) {
244
246
  try {
245
247
  beforeStatus = await readGitStatusPorcelain(input.cwd);
248
+ beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
246
249
  }
247
250
  catch {
248
251
  beforeStatus = undefined;
252
+ beforePathFingerprints = undefined;
249
253
  }
250
254
  }
251
255
  const result = await piStepFn({
@@ -278,15 +282,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
278
282
  }
279
283
  let writeGuardOk = true;
280
284
  let writeGuardViolations = [];
285
+ let changeManifestAfterStatus;
286
+ let changeManifestChangedFiles;
281
287
  if (beforeStatus !== undefined) {
282
288
  try {
283
- const guard = await runPostRunWriteGuard({
284
- rootCwd: input.cwd,
285
- beforeStatus,
286
- writePolicy: input.task.writePolicy,
287
- writeSet: input.task.writeSet,
288
- allowedPaths: input.task.allowedPaths,
289
- forbiddenPaths: input.task.forbiddenPaths,
289
+ const afterStatus = await readGitStatusPorcelain(input.cwd);
290
+ const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
291
+ const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
292
+ const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
293
+ changeManifestAfterStatus = afterStatus;
294
+ changeManifestChangedFiles = changedFiles;
295
+ const guard = validateShellWriteGuardFromDiff({
296
+ changedFiles,
297
+ task: input.task,
290
298
  concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
291
299
  });
292
300
  writeGuardOk = guard.ok;
@@ -300,6 +308,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
300
308
  }
301
309
  }
302
310
  if (writeGuardOk) {
311
+ if (beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
312
+ await persistWriterChangeManifest({
313
+ runDir: meta.runDir,
314
+ nodeId: input.task.id,
315
+ writerNodeId: input.task.id,
316
+ changedFiles: changeManifestChangedFiles,
317
+ beforeStatus,
318
+ afterStatus: changeManifestAfterStatus ?? "",
319
+ });
320
+ }
303
321
  return mapped;
304
322
  }
305
323
  const stderrParts = [mapped.stderr];
@@ -335,11 +353,18 @@ function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
335
353
  return assistantText;
336
354
  const lines = assistantText.split(/\r?\n/);
337
355
  let protocolIndex = -1;
356
+ let protocolNextLineIndex = -1;
338
357
  let protocolLine = "";
339
358
  for (const [index, line] of lines.entries()) {
340
- const normalized = normalizeProtocolLine(line);
341
- if (normalized.startsWith(firstProtocolLine)) {
359
+ const nextLine = lines[index + 1];
360
+ const normalized = normalizeProtocolLine(line, firstProtocolLine, nextLine);
361
+ if (normalized !== undefined) {
342
362
  protocolIndex = index;
363
+ if (normalizeProtocolLine(line, firstProtocolLine) === undefined &&
364
+ nextLine !== undefined &&
365
+ normalizedProtocolValue(nextLine) !== "") {
366
+ protocolNextLineIndex = index + 1;
367
+ }
343
368
  protocolLine = normalized;
344
369
  break;
345
370
  }
@@ -347,10 +372,14 @@ function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
347
372
  if (protocolIndex < 0)
348
373
  return assistantText;
349
374
  if (protocolIndex === 0) {
350
- return [protocolLine, ...lines.slice(1)].join("\n");
375
+ const remaining = lines.slice(protocolNextLineIndex === 1 ? 2 : 1);
376
+ return [protocolLine, ...remaining].join("\n");
351
377
  }
352
378
  const before = lines.slice(0, protocolIndex);
353
379
  const after = lines.slice(protocolIndex + 1);
380
+ if (protocolNextLineIndex === protocolIndex + 1) {
381
+ after.shift();
382
+ }
354
383
  while (before.at(-1)?.trim() === "" &&
355
384
  after.at(0)?.trim() === "") {
356
385
  after.shift();
@@ -364,8 +393,81 @@ function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
364
393
  ? `${protocolLine}\n\n${bodyLines.join("\n")}`
365
394
  : protocolLine;
366
395
  }
367
- function normalizeProtocolLine(line) {
368
- const trimmed = line.trim();
369
- const emphasized = trimmed.match(/^(\*{1,3})\s*(.*?)\s*\1$/);
370
- return (emphasized?.[2] ?? trimmed).trim();
396
+ function normalizeProtocolLine(line, firstProtocolLine, nextLine) {
397
+ const normalizedLine = stripMarkdownLineDecorations(line);
398
+ if (!firstProtocolLine)
399
+ return normalizedLine;
400
+ const label = firstProtocolLine.replace(/[::]\s*$/, "").trim();
401
+ const labelPattern = label
402
+ .split(/[_\-\s]+/)
403
+ .filter(Boolean)
404
+ .map(escapeRegExp)
405
+ .join("[_\\-\\s]+");
406
+ if (!labelPattern)
407
+ return undefined;
408
+ const direct = normalizedLine.match(new RegExp(`^${labelPattern}\\s*[::]\\s*(.+)$`, "i"));
409
+ if (direct) {
410
+ const value = normalizedProtocolValue(direct[1] ?? "");
411
+ return value ? `${firstProtocolLine} ${value}` : undefined;
412
+ }
413
+ const splitValue = normalizedLine.match(new RegExp(`^${labelPattern}\\s*[::]?\\s*$`, "i"));
414
+ if (splitValue && nextLine !== undefined) {
415
+ const value = normalizedProtocolValue(nextLine);
416
+ return value ? `${firstProtocolLine} ${value}` : undefined;
417
+ }
418
+ return undefined;
419
+ }
420
+ function normalizedProtocolValue(value) {
421
+ let normalized = stripMarkdownLineDecorations(value);
422
+ normalized = normalized.replace(/^[::]\s*/, "").trim();
423
+ normalized = normalized.replace(/[。.!;;,,]+$/u, "").trim();
424
+ normalized = stripMarkdownLineDecorations(normalized);
425
+ return normalized;
426
+ }
427
+ function stripMarkdownLineDecorations(line) {
428
+ let trimmed = line.trim();
429
+ trimmed = trimmed.replace(/^(?:>\s*)+/, "");
430
+ trimmed = trimmed.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, "");
431
+ const emphasized = trimmed.match(/^(\*{1,3}|_{1,3}|`{1,3})\s*(.*?)\s*\1$/);
432
+ trimmed = (emphasized?.[2] ?? trimmed).trim();
433
+ return trimmed;
434
+ }
435
+ function escapeRegExp(value) {
436
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
437
+ }
438
+ /**
439
+ * Validate the writer's observed diff against its declared write boundary.
440
+ * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
441
+ * so the change manifest and the guard share one source of truth.
442
+ */
443
+ function validateShellWriteGuardFromDiff(input) {
444
+ return validateShellWriteGuard({
445
+ changedPaths: input.changedFiles,
446
+ writePolicy: input.task.writePolicy,
447
+ writeSet: input.task.writeSet,
448
+ allowedPaths: input.task.allowedPaths,
449
+ forbiddenPaths: input.task.forbiddenPaths,
450
+ concurrentSiblingWriteSets: input.concurrentSiblingWriteSets,
451
+ allowSiblingWriteSetAttribution: input.task.writePolicy === "exclusive" &&
452
+ (input.concurrentSiblingWriteSets?.length ?? 0) > 0,
453
+ });
454
+ }
455
+ /**
456
+ * Persist the writer's actual changeset as a run-owned artifact so the
457
+ * project governance context resolver can attribute exactly this run's
458
+ * writes (not final `git status`, which may include pre-existing changes).
459
+ */
460
+ async function persistWriterChangeManifest(input) {
461
+ const manifest = {
462
+ schemaVersion: 1,
463
+ writerNodeId: input.writerNodeId,
464
+ changedFiles: input.changedFiles,
465
+ beforeStatusSha256: createHash("sha256")
466
+ .update(input.beforeStatus)
467
+ .digest("hex"),
468
+ afterStatusSha256: createHash("sha256")
469
+ .update(input.afterStatus)
470
+ .digest("hex"),
471
+ };
472
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "change-manifest.json", manifest);
371
473
  }
@@ -50,6 +50,10 @@ export function extractSubagentStats(stdout) {
50
50
  export const DEFAULT_TIMEOUT_MS = 1_800_000;
51
51
  export const MAX_ALLOWED_TIMEOUT_MS = 1_800_000;
52
52
  const PI_BACKEND_ENV = 'CODE_AGENT_PI_BACKEND';
53
+ const OUTPUT_PREVIEW_HEAD_CHARS = 64_000;
54
+ const OUTPUT_PREVIEW_TAIL_CHARS = 64_000;
55
+ const MAX_STREAM_JSONL_LINE_CHARS = 16_000_000;
56
+ const MAX_ASSISTANT_TEXT_CHARS = 16_000_000;
53
57
  const PI_CLI_PACKAGE_NAMES = [
54
58
  '@earendil-works/pi-coding-agent',
55
59
  '@mariozechner/pi-coding-agent',
@@ -186,6 +190,10 @@ function resolvePiCliPath() {
186
190
  path.join(nvmBase, nodeVersion, 'lib', 'node_modules'),
187
191
  ].flatMap((nodeModulesRoot) => PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(nodeModulesRoot, packageName)));
188
192
  const otherCandidates = [
193
+ // Prefer the project-local Pi dependency. `npm run` adds node_modules/.bin
194
+ // to PATH, but direct invocations from the DAG runner may not, so resolve
195
+ // the package from the current workspace explicitly.
196
+ ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.resolve(process.cwd(), 'node_modules'), packageName)),
189
197
  // Global npm installation (non-nvm)
190
198
  ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot('/usr/local/lib/node_modules', packageName)),
191
199
  // Bun installation
@@ -327,8 +335,167 @@ function extractAssistantTextFromMessage(message) {
327
335
  function isRecord(value) {
328
336
  return typeof value === 'object' && value !== null;
329
337
  }
338
+ export class BoundedTextPreview {
339
+ label;
340
+ headMax;
341
+ tailMax;
342
+ parts = [];
343
+ head = '';
344
+ tail = '';
345
+ bufferedChars = 0;
346
+ totalChars = 0;
347
+ truncated = false;
348
+ constructor(label, headMax = OUTPUT_PREVIEW_HEAD_CHARS, tailMax = OUTPUT_PREVIEW_TAIL_CHARS) {
349
+ this.label = label;
350
+ this.headMax = headMax;
351
+ this.tailMax = tailMax;
352
+ }
353
+ append(chunk) {
354
+ if (!chunk)
355
+ return;
356
+ this.totalChars += chunk.length;
357
+ if (!this.truncated) {
358
+ if (this.bufferedChars + chunk.length <= this.headMax + this.tailMax) {
359
+ this.parts.push(chunk);
360
+ this.bufferedChars += chunk.length;
361
+ return;
362
+ }
363
+ const combined = this.parts.join('') + chunk;
364
+ this.head = combined.slice(0, this.headMax);
365
+ this.tail = combined.slice(-this.tailMax);
366
+ this.parts = [];
367
+ this.bufferedChars = 0;
368
+ this.truncated = true;
369
+ return;
370
+ }
371
+ this.tail = (this.tail + chunk).slice(-this.tailMax);
372
+ }
373
+ text() {
374
+ if (!this.truncated)
375
+ return this.parts.join('');
376
+ const omitted = Math.max(0, this.totalChars - this.head.length - this.tail.length);
377
+ return [
378
+ this.head,
379
+ `[${this.label} truncated: omitted ${omitted} chars; kept first ${this.head.length} and last ${this.tail.length}]`,
380
+ this.tail,
381
+ ].join('\n');
382
+ }
383
+ }
384
+ export class PiJsonlStreamCollector {
385
+ lineBuffer = '';
386
+ discardUntilNewline = false;
387
+ assistantText = '';
388
+ parsedEvents = 0;
389
+ tokensUsed = 0;
390
+ outputTooLarge = false;
391
+ subagentTotalCalls = 0;
392
+ subagentFailedCalls = 0;
393
+ subagentModes = new Set();
394
+ subagentAgents = new Set();
395
+ append(chunk) {
396
+ if (!chunk)
397
+ return;
398
+ const lines = chunk.split(/\r?\n/);
399
+ for (let index = 0; index < lines.length; index += 1) {
400
+ const part = lines[index] ?? '';
401
+ const isLast = index === lines.length - 1;
402
+ if (this.discardUntilNewline) {
403
+ if (!isLast)
404
+ this.discardUntilNewline = false;
405
+ continue;
406
+ }
407
+ if (this.lineBuffer.length + part.length > MAX_STREAM_JSONL_LINE_CHARS) {
408
+ this.outputTooLarge = true;
409
+ this.lineBuffer = '';
410
+ if (isLast)
411
+ this.discardUntilNewline = true;
412
+ continue;
413
+ }
414
+ this.lineBuffer += part;
415
+ if (!isLast) {
416
+ this.consumeLine(this.lineBuffer);
417
+ this.lineBuffer = '';
418
+ }
419
+ }
420
+ }
421
+ finish() {
422
+ if (!this.discardUntilNewline && this.lineBuffer.trim()) {
423
+ this.consumeLine(this.lineBuffer);
424
+ }
425
+ this.lineBuffer = '';
426
+ return {
427
+ assistantText: this.assistantText.trim(),
428
+ outputTooLarge: this.outputTooLarge,
429
+ parsedEvents: this.parsedEvents,
430
+ subagentStats: this.formatSubagentStats(),
431
+ tokensUsed: this.tokensUsed,
432
+ };
433
+ }
434
+ consumeLine(line) {
435
+ const trimmed = line.trim();
436
+ if (!trimmed)
437
+ return;
438
+ try {
439
+ const event = JSON.parse(trimmed);
440
+ this.parsedEvents += 1;
441
+ this.consumeEvent(event);
442
+ }
443
+ catch {
444
+ // tolerate non-json noise
445
+ }
446
+ }
447
+ consumeEvent(event) {
448
+ const candidate = extractAssistantTextFromEvent(event);
449
+ if (candidate) {
450
+ if (candidate.length > MAX_ASSISTANT_TEXT_CHARS) {
451
+ this.outputTooLarge = true;
452
+ this.assistantText = candidate.slice(0, MAX_ASSISTANT_TEXT_CHARS);
453
+ }
454
+ else {
455
+ this.assistantText = candidate;
456
+ }
457
+ }
458
+ const usage = extractUsageFromEvent(event);
459
+ if (usage !== null)
460
+ this.tokensUsed += usage;
461
+ if (event.type === 'tool_execution_start' && event.toolName === 'subagent') {
462
+ this.subagentTotalCalls += 1;
463
+ if (isRecord(event.input)) {
464
+ if (event.input.agent)
465
+ this.subagentAgents.add(String(event.input.agent));
466
+ if (event.input.tasks)
467
+ this.subagentModes.add('parallel');
468
+ if (event.input.chain)
469
+ this.subagentModes.add('chain');
470
+ if (event.input.agent && !event.input.tasks && !event.input.chain) {
471
+ this.subagentModes.add('single');
472
+ }
473
+ }
474
+ }
475
+ if (event.type === 'tool_execution_end' && event.toolName === 'subagent') {
476
+ if (event.isError === true)
477
+ this.subagentFailedCalls += 1;
478
+ }
479
+ }
480
+ formatSubagentStats() {
481
+ if (this.subagentTotalCalls === 0)
482
+ return 'calls=0';
483
+ const parts = [`calls=${this.subagentTotalCalls}`];
484
+ if (this.subagentFailedCalls > 0)
485
+ parts.push(`${this.subagentFailedCalls} failed`);
486
+ if (this.subagentModes.size > 0)
487
+ parts.push(Array.from(this.subagentModes).sort().join('+'));
488
+ if (this.subagentAgents.size > 0) {
489
+ parts.push(`agents=[${Array.from(this.subagentAgents).sort().join(',')}]`);
490
+ }
491
+ return parts.join(' | ');
492
+ }
493
+ }
494
+ export function createPiJsonlStreamCollector() {
495
+ return new PiJsonlStreamCollector();
496
+ }
330
497
  function withSubagentStats(result) {
331
- return { ...result, subagentStats: extractSubagentStats(result.stdout) };
498
+ return { ...result, subagentStats: result.subagentStats ?? extractSubagentStats(result.stdout) };
332
499
  }
333
500
  function withCliBackend(result) {
334
501
  return { ...result, backend: 'cli' };
@@ -354,7 +521,10 @@ async function runCliOrchestration(options, meta) {
354
521
  const fallbackResult = await executeSingleCliAttempt(options, primary.fallback);
355
522
  attemptedModels.push(fallbackResult.modelDisplay);
356
523
  const combinedStderr = [primaryResult.stderr, fallbackResult.stderr].filter(Boolean).join('\n--- fallback ---\n');
357
- const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n');
524
+ const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n--- fallback ---\n');
525
+ const combinedStdoutTruncated = primaryResult.stdoutTruncated || fallbackResult.stdoutTruncated;
526
+ const combinedStderrTruncated = primaryResult.stderrTruncated || fallbackResult.stderrTruncated;
527
+ const combinedOutputTooLarge = primaryResult.outputTooLarge || fallbackResult.outputTooLarge;
358
528
  let fallbackIssues = [];
359
529
  if (fallbackResult.ok && options.validateOutput) {
360
530
  fallbackIssues = options.validateOutput(fallbackResult.assistantText);
@@ -367,6 +537,9 @@ async function runCliOrchestration(options, meta) {
367
537
  sdkAttempted: meta?.sdkAttempted,
368
538
  stderr: combinedStderr,
369
539
  stdout: combinedStdout,
540
+ stdoutTruncated: combinedStdoutTruncated,
541
+ stderrTruncated: combinedStderrTruncated,
542
+ outputTooLarge: combinedOutputTooLarge,
370
543
  }));
371
544
  }
372
545
  if (fallbackResult.ok && fallbackIssues.length > 0) {
@@ -379,6 +552,9 @@ async function runCliOrchestration(options, meta) {
379
552
  sdkAttempted: meta?.sdkAttempted,
380
553
  stderr: combinedStderr + `\n[invalid-output] ${fallbackIssues.join('; ')}`,
381
554
  stdout: combinedStdout,
555
+ stdoutTruncated: combinedStdoutTruncated,
556
+ stderrTruncated: combinedStderrTruncated,
557
+ outputTooLarge: combinedOutputTooLarge,
382
558
  }));
383
559
  }
384
560
  return withSubagentStats(withCliBackend({
@@ -388,6 +564,9 @@ async function runCliOrchestration(options, meta) {
388
564
  sdkAttempted: meta?.sdkAttempted,
389
565
  stderr: combinedStderr,
390
566
  stdout: combinedStdout,
567
+ stdoutTruncated: combinedStdoutTruncated,
568
+ stderrTruncated: combinedStderrTruncated,
569
+ outputTooLarge: combinedOutputTooLarge,
391
570
  }));
392
571
  }
393
572
  if (primaryInvalidOutput) {
@@ -516,12 +695,18 @@ async function executeSingleCliAttempt(options, modelConfig) {
516
695
  },
517
696
  stdio: ['ignore', 'pipe', 'pipe'],
518
697
  });
519
- let stdout = '';
520
- let stderr = '';
698
+ const stdoutPreview = new BoundedTextPreview('stdout');
699
+ const stderrPreview = new BoundedTextPreview('stderr', 16_000, 16_000);
700
+ const stdoutCollector = new PiJsonlStreamCollector();
521
701
  child.stdout.setEncoding('utf-8');
522
702
  child.stderr.setEncoding('utf-8');
523
- child.stdout.on('data', (chunk) => { stdout += chunk; });
524
- child.stderr.on('data', (chunk) => { stderr += chunk; });
703
+ child.stdout.on('data', (chunk) => {
704
+ stdoutPreview.append(chunk);
705
+ stdoutCollector.append(chunk);
706
+ });
707
+ child.stderr.on('data', (chunk) => {
708
+ stderrPreview.append(chunk);
709
+ });
525
710
  child.on('error', (err) => {
526
711
  if (timeoutHandle)
527
712
  clearTimeout(timeoutHandle);
@@ -535,28 +720,34 @@ async function executeSingleCliAttempt(options, modelConfig) {
535
720
  if (sigkillHandle)
536
721
  clearTimeout(sigkillHandle);
537
722
  const durationMs = Date.now() - startedAt;
538
- const { assistantText, parsedEvents } = extractAssistantTextFromPiJson(stdout);
539
- const tokensUsed = extractTokenUsageFromPiJson(stdout);
723
+ const stdout = stdoutPreview.text();
724
+ const stderr = stderrPreview.text();
725
+ const collected = stdoutCollector.finish();
540
726
  const failureCategory = classifyPiFailure({
541
- assistantText,
727
+ assistantText: collected.assistantText,
542
728
  exitCode: code,
543
729
  stderr,
544
730
  stdout,
545
731
  timedOut,
732
+ outputTooLarge: collected.outputTooLarge,
546
733
  });
547
734
  resolve({
548
- assistantText,
735
+ assistantText: collected.assistantText,
549
736
  command: ['pi', ...piArgs],
550
737
  durationMs,
551
738
  exitCode: code,
552
739
  failureCategory,
553
740
  modelDisplay,
554
- ok: code === 0 && assistantText.length > 0,
555
- parsedEvents,
741
+ ok: code === 0 && collected.assistantText.length > 0 && !collected.outputTooLarge,
742
+ parsedEvents: collected.parsedEvents,
556
743
  stderr,
557
744
  stdout,
745
+ stdoutTruncated: stdoutPreview.truncated,
746
+ stderrTruncated: stderrPreview.truncated,
747
+ outputTooLarge: collected.outputTooLarge,
558
748
  timedOut,
559
- tokensUsed,
749
+ tokensUsed: collected.tokensUsed,
750
+ subagentStats: collected.subagentStats,
560
751
  });
561
752
  });
562
753
  if (timeoutMs > 0) {
@@ -585,6 +776,8 @@ export function shouldRetryWithFallback(stderr, stdout) {
585
776
  export function classifyPiFailure(input) {
586
777
  if (input.timedOut)
587
778
  return 'timeout';
779
+ if (input.outputTooLarge)
780
+ return 'output-too-large';
588
781
  if (input.exitCode === 0 && input.assistantText.trim())
589
782
  return 'success';
590
783
  const combined = `${input.stderr}\n${input.stdout}`.toLowerCase();
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { classifyPiFailure, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
4
4
  import { serializeSessionEvent } from './pi-event-serializer.js';
5
5
  let sdkSessionFactoryOverride;
6
6
  let sdkImportOverrideForTests;
@@ -283,7 +283,8 @@ export async function executeSingleSdkAttempt(options) {
283
283
  const startedAt = Date.now();
284
284
  let timedOut = false;
285
285
  let stderr = '';
286
- const stdoutLines = [];
286
+ const stdoutPreview = new BoundedTextPreview('stdout');
287
+ const stdoutCollector = createPiJsonlStreamCollector();
287
288
  const usageSamples = [];
288
289
  let session;
289
290
  let timeoutHandle;
@@ -307,7 +308,8 @@ export async function executeSingleSdkAttempt(options) {
307
308
  if (!shouldPersistSessionEvent(event))
308
309
  return;
309
310
  const line = serializeSessionEvent(event);
310
- stdoutLines.push(line);
311
+ stdoutPreview.append(`${line}\n`);
312
+ stdoutCollector.append(`${line}\n`);
311
313
  sessionEventAppender?.append(line, event);
312
314
  });
313
315
  const filePrefix = options.attachedFiles.map((file) => `@${file}`).join(' ');
@@ -363,13 +365,23 @@ export async function executeSingleSdkAttempt(options) {
363
365
  }
364
366
  }
365
367
  }
366
- const stdout = stdoutLines.join('\n');
368
+ const stdout = stdoutPreview.text();
367
369
  const durationMs = Date.now() - startedAt;
368
- const { assistantText, parsedEvents } = extractAssistantTextFromPiJson(stdout);
370
+ const collected = stdoutCollector.finish();
371
+ const fallbackParsed = collected.parsedEvents > 0
372
+ ? undefined
373
+ : extractAssistantTextFromPiJson(stdout);
374
+ const assistantText = collected.parsedEvents > 0
375
+ ? collected.assistantText
376
+ : fallbackParsed?.assistantText ?? '';
377
+ const parsedEvents = collected.parsedEvents > 0
378
+ ? collected.parsedEvents
379
+ : fallbackParsed?.parsedEvents ?? 0;
369
380
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
370
381
  const failureCategory = classifyPiFailure({
371
382
  assistantText,
372
383
  exitCode: timedOut ? 1 : stderr ? 1 : 0,
384
+ outputTooLarge: collected.outputTooLarge,
373
385
  stderr,
374
386
  stdout,
375
387
  timedOut,
@@ -382,11 +394,14 @@ export async function executeSingleSdkAttempt(options) {
382
394
  exitCode: timedOut || stderr ? 1 : 0,
383
395
  failureCategory,
384
396
  modelDisplay,
385
- ok: !timedOut && !stderr && assistantText.length > 0,
397
+ ok: !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
386
398
  parsedEvents,
387
399
  stderr,
388
400
  stdout,
401
+ stdoutTruncated: stdoutPreview.truncated,
402
+ outputTooLarge: collected.outputTooLarge,
389
403
  timedOut,
390
404
  tokensUsed,
405
+ subagentStats: collected.subagentStats,
391
406
  };
392
407
  }