@tea-agent/loop-agent 0.39.0-next.26 → 0.39.0-next.28

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/AGENTS.md +3 -0
  2. package/CHANGELOG.md +148 -96
  3. package/README.md +8 -4
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/command-definitions.js +16 -5
  7. package/dist/cli/program.js +16 -2
  8. package/dist/cli/update/notifier.js +2 -2
  9. package/dist/cli.js +2 -1
  10. package/dist/commands/init-model-catalog.js +464 -0
  11. package/dist/commands/init-upgrade.js +265 -97
  12. package/dist/commands/init.js +428 -27
  13. package/dist/commands/inspect-next.js +8 -0
  14. package/dist/executors/dag-pi-executor.js +76 -10
  15. package/dist/executors/pi-extension-resolver.js +233 -0
  16. package/dist/executors/pi-sdk-executor.js +79 -2
  17. package/dist/executors/shell-executor.js +50 -6
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/shared/operator/capabilities.js +7 -0
  20. package/dist/shared/update/console-notifier.js +100 -0
  21. package/dist/{cli → shared}/update/npm-client.js +41 -8
  22. package/dist/{cli → shared}/update/state.js +41 -13
  23. package/dist/worker/cli.js +31 -1
  24. package/dist/worker/console/chat/routes.js +1 -0
  25. package/dist/worker/console/operator-user-error.js +1 -1
  26. package/dist/worker/console/routes.js +3 -0
  27. package/dist/worker/console/security.js +53 -5
  28. package/dist/worker/console/server.js +10 -4
  29. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-DGn9iNrd.js} +1 -1
  30. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-Dc0C2Nyv.js} +1 -1
  31. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-COnz_Bhe.js} +1 -1
  32. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-VhIP6vgf.js} +1 -1
  33. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-D74lpmlb.js} +1 -1
  34. package/dist/worker/console/static/assets/channel-DWcZjWB3.js +1 -0
  35. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-BxmiogQE.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-C00Oi-2I.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-DTrT1Vze.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-DdKW7-fL.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-D0q1WN6w.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-DBa14MSI.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-DkP-pEox.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-lgPogpcX.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-D-apZer0.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-B1T2yAIe.js} +1 -1
  45. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-IjoI3dqU.js +1 -0
  46. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-IjoI3dqU.js +1 -0
  47. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-0P6hi3jF.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-CntzMSR8.js} +1 -1
  49. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-C9zmXJtG.js} +1 -1
  50. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-DfBCZqZw.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-DLp-bhGL.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-BksDcwZm.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-Bdag7DYP.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-CoY5uNbk.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-KcgrDXHt.js} +1 -1
  56. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-CikErwJb.js} +1 -1
  57. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-DqmwwzvZ.js} +1 -1
  58. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-35krh9WG.js} +1 -1
  59. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-CvTF1gJ4.js} +1 -1
  60. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-BiOWWClJ.js} +1 -1
  61. package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-CqWlWE4l.js} +3 -3
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-Crha5dcA.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-DEtbscOZ.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-D2YYzx3z.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-C7x18ttQ.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-CCLZkz45.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-Dmy4lSGv.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-CmvIol59.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-DVpEdirN.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-cKQy456v.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-bx06I38m.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-BjfIVIJr.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-DcuPGbKM.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-DvgVeDoV.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-DLBG9nm-.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-D33L7FBF.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-hvqD9TpS.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C6MII0D1.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-BtjH81Mz.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D_tk0V8D.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-CJ47Kraf.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-COVPl-wh.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-CCoQJLUY.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-DTWJvfAd.js} +1 -1
  85. package/dist/worker/console/static/index.html +1 -1
  86. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  87. package/dist/worker/observe/node-input.js +72 -3
  88. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  89. package/dist/worker/observe/static/views/dag-inspector.js +54 -1
  90. package/dist/worker/observe/static/views/session-timeline.js +60 -4
  91. package/dist/workflows/dag/contract-output-registry.js +15 -0
  92. package/dist/workflows/dag/failure-category.js +5 -0
  93. package/dist/workflows/dag/frontend-implementation-contract.js +145 -40
  94. package/dist/workflows/dag/frontend-prewrite-gate.js +87 -135
  95. package/dist/workflows/dag/init-hybrid.js +71 -80
  96. package/dist/workflows/dag/node-execution.js +139 -10
  97. package/dist/workflows/dag/prompt.js +15 -1
  98. package/dist/workflows/dag/retry-policy.js +91 -0
  99. package/dist/workflows/dag/structured-output-repair.js +712 -0
  100. package/dist/workflows/dag/types.js +28 -1
  101. package/dist/workflows/dag/validate.js +39 -1
  102. package/docs/init-surface.manifest.json +5 -0
  103. package/docs/operations/README.md +1 -1
  104. package/docs/templates/agent-dag.schema.json +13 -0
  105. package/docs/templates/init-managed-agents.md +13 -3
  106. package/package.json +5 -1
  107. package/scripts/next-info.mjs +356 -0
  108. package/scripts/next-publish-gate.mjs +238 -0
  109. package/scripts/release-source-binding.mjs +251 -0
  110. package/skills/loop-agent/references/command-reference.md +11 -4
  111. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  112. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  113. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  114. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  115. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
@@ -0,0 +1,8 @@
1
+ import { collectNextInfo, formatNextInfo, } from "../../scripts/next-info.mjs";
2
+ export async function runInspectNext(repoRoot, args = []) {
3
+ const json = args.includes("--json");
4
+ const result = collectNextInfo({ cwd: repoRoot });
5
+ process.stdout.write(formatNextInfo(result, { json }));
6
+ if (!result.ok)
7
+ process.exitCode = result.exitCode || 1;
8
+ }
@@ -2,13 +2,14 @@ import path from "node:path";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
5
- import { executePiStep, } from "./pi-executor.js";
5
+ import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
6
+ import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
6
7
  import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
7
8
  import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
8
9
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
9
10
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
10
11
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
11
- import { isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
12
+ import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
12
13
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
13
14
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
14
15
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
@@ -327,6 +328,36 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
327
328
  await writeTextArtifactFile(summaryPath, buildPiResultSummaryMarkdown(input));
328
329
  return { promptPath, summaryPath };
329
330
  }
331
+ /**
332
+ * Run-owned per-node Pi extension facts (plan 2026-08-21): requested ids,
333
+ * resolved packages (with absolute entry paths), and degraded/missing ids.
334
+ * Written only for nodes that declare `piExtensions`; never into the repo-level
335
+ * DAG template. Best-effort: artifact failures never fail the node.
336
+ */
337
+ async function writePiExtensionsArtifact(nodeArtifactsDir, requested, resolved, missing) {
338
+ try {
339
+ const runDir = path.dirname(nodeArtifactsDir);
340
+ const nodeId = path.basename(nodeArtifactsDir);
341
+ await writeDagNodeJsonArtifact(runDir, nodeId, "pi-extensions.json", {
342
+ schemaVersion: 1,
343
+ requested: [...requested],
344
+ resolved: resolved.map((entry) => ({
345
+ id: entry.id,
346
+ packageName: entry.packageName,
347
+ version: entry.version,
348
+ path: entry.entryPaths.join(", "),
349
+ })),
350
+ missing: missing.map((entry) => ({
351
+ id: entry.id,
352
+ reason: entry.reason,
353
+ ...(entry.detail ? { detail: entry.detail } : {}),
354
+ })),
355
+ });
356
+ }
357
+ catch {
358
+ // best-effort run fact; node execution continues
359
+ }
360
+ }
330
361
  async function resolveValidatedFrontendBaseUrlFromContext(workspaceRoot, runDir) {
331
362
  try {
332
363
  const capability = JSON.parse(await readFile(path.join(runDir, "preflight-frontend-browser-tool-shell", "frontend-browser-capability.json"), "utf8"));
@@ -550,6 +581,29 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
550
581
  };
551
582
  }
552
583
  }
584
+ // Plan 2026-08-21 D4/D6: resolve the node's explicit Pi extension
585
+ // allowlist against the settings inventory. Degrade on missing packages —
586
+ // never fail the node — and persist requested/resolved/missing facts.
587
+ const piExtensionsResolution = input.task.piExtensions
588
+ ? resolveDagPiExtensions({
589
+ repoRoot: input.cwd,
590
+ ids: input.task.piExtensions,
591
+ })
592
+ : undefined;
593
+ if (piExtensionsResolution) {
594
+ const piBackend = resolvePiBackend();
595
+ const cliDegraded = piBackend === "cli-only";
596
+ await writePiExtensionsArtifact(path.join(meta.runDir, input.task.id), input.task.piExtensions, cliDegraded ? [] : piExtensionsResolution.resolved, cliDegraded
597
+ ? input.task.piExtensions.map((id) => ({
598
+ id,
599
+ reason: "not-installed",
600
+ detail: "cli backend does not load pi extensions (sdk-only v1)",
601
+ }))
602
+ : piExtensionsResolution.missing);
603
+ }
604
+ const piExtensionPaths = piExtensionsResolution && resolvePiBackend() !== "cli-only"
605
+ ? piExtensionsResolution.resolved.flatMap((entry) => entry.entryPaths)
606
+ : undefined;
553
607
  result = await piStepFn({
554
608
  attachedFiles: [],
555
609
  modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
@@ -564,6 +618,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
564
618
  stallTimeoutMs: input.stallTimeoutMs,
565
619
  abortGraceMs: input.abortGraceMs,
566
620
  ...(writerToolPolicy ? { writerToolPolicy } : {}),
621
+ ...(piExtensionPaths && piExtensionPaths.length > 0
622
+ ? { piExtensionPaths }
623
+ : {}),
567
624
  onActivity: bridgeActivity
568
625
  ? (activity) => {
569
626
  if (activity.kind === "lease" ||
@@ -885,22 +942,31 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
885
942
  if (meta.writeGuardAttribution === "best-effort") {
886
943
  stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
887
944
  }
888
- // Pure provider stall/timeout with zero workspace side effects is eligible
889
- // for a single writer transport retry (implement-pi flake resilience).
890
- // Partial writes, tool calls, or write-guard failures keep raw `timeout`.
891
- const writerCleanTimeout = !mapped.ok &&
892
- mapped.failureCategory === "timeout" &&
893
- writeGuardOk &&
945
+ const rawFailureCategory = mapped.failureCategory;
946
+ const writeToolCallCount = readWriterThinkingExhaustionEvidence(result).writeToolCallCount;
947
+ const zeroSideEffects = writeGuardOk &&
894
948
  !completenessFailure &&
895
949
  !writerThinkingExhausted &&
896
- isWriterTransportRetryCandidate(input.task) &&
950
+ !writerOutcomeViolation &&
897
951
  changeManifestChangedFiles !== undefined &&
898
952
  changeManifestChangedFiles.length === 0 &&
899
- readWriterThinkingExhaustionEvidence(result).writeToolCallCount === 0;
953
+ writeToolCallCount === 0;
954
+ const targetCleanTransport = !mapped.ok &&
955
+ isTargetTemplateTransientRetryNode(input.task) &&
956
+ rawFailureCategory !== undefined &&
957
+ ["timeout", "network", "rate-limit", "unavailable"].includes(rawFailureCategory) &&
958
+ zeroSideEffects;
959
+ const frontendCleanTimeout = !mapped.ok &&
960
+ rawFailureCategory === "timeout" &&
961
+ isWriterTransportRetryCandidate(input.task) &&
962
+ !isTargetTemplateTransientRetryNode(input.task) &&
963
+ zeroSideEffects;
964
+ const writerCleanTimeout = targetCleanTransport || frontendCleanTimeout;
900
965
  return {
901
966
  ...mapped,
902
967
  ok: false,
903
968
  stderr: stderrParts.filter(Boolean).join("\n\n"),
969
+ ...(rawFailureCategory ? { rawFailureCategory } : {}),
904
970
  failureCategory: mapped.ok
905
971
  ? writeGuardOk
906
972
  ? completenessFailure
@@ -0,0 +1,233 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Pi extension allowlist for backend implementation DAGs (plan D2/D3).
6
+ * DAG specs carry these frozen short ids; the executor resolves them against
7
+ * the Pi agent settings package inventory at run time.
8
+ */
9
+ export const DAG_PI_EXTENSION_IDS = ["pi-codegraph", "pi-lens"];
10
+ /**
11
+ * Alias table: one DAG short id may resolve to multiple published package
12
+ * names. The first settings spec whose de-`npm:`-prefixed package name is in
13
+ * the alias set wins (project settings override user settings).
14
+ */
15
+ export const DAG_PI_EXTENSION_PACKAGE_ALIASES = {
16
+ "pi-codegraph": [
17
+ "@estebanforge/pi-codegraph-enhanced",
18
+ "@vndv/pi-codegraph",
19
+ "pi-codegraph",
20
+ ],
21
+ "pi-lens": ["pi-lens"],
22
+ };
23
+ /**
24
+ * Extension tools that must never be activated as extras on any node (D5):
25
+ * they mutate files without the writer path policy. Writer nodes keep only
26
+ * policy-wrapped edit/write; read nodes keep only navigation tools.
27
+ */
28
+ export const DAG_PI_EXTENSION_WRITE_TOOL_DENY = new Set([
29
+ "ast_grep_replace",
30
+ ]);
31
+ /** Builtin tool names extensions must never replace (D5). */
32
+ export const DAG_PI_EXTENSION_PROTECTED_BUILTIN_TOOLS = new Set(["read", "edit", "write", "bash", "grep", "find", "ls"]);
33
+ /** Deny surface extensions must never activate (D5). */
34
+ export const DAG_PI_EXTENSION_DENIED_TOOL_IDS = new Set([
35
+ "apply_patch",
36
+ "apply-patch",
37
+ "full-tools",
38
+ "full_tools",
39
+ "coding-chat",
40
+ "coding_chat",
41
+ "shell",
42
+ ]);
43
+ export function isDagPiExtensionId(value) {
44
+ return DAG_PI_EXTENSION_IDS.includes(value);
45
+ }
46
+ function getPiAgentDir() {
47
+ const envDir = process.env.PI_CODING_AGENT_DIR;
48
+ if (envDir)
49
+ return envDir;
50
+ return path.join(os.homedir(), ".pi", "agent");
51
+ }
52
+ function readSettingsPackages(settingsPath) {
53
+ try {
54
+ if (!existsSync(settingsPath))
55
+ return undefined;
56
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
57
+ if (!Array.isArray(parsed.packages))
58
+ return undefined;
59
+ return parsed.packages.filter((spec) => typeof spec === "string");
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ /** Strip an `npm:` prefix from a settings package spec, e.g. `npm:pi-lens`. */
66
+ export function packageNameFromSpec(spec) {
67
+ return spec.startsWith("npm:") ? spec.slice(4) : spec;
68
+ }
69
+ function loadPackageInventories(repoRoot) {
70
+ const agentDir = getPiAgentDir();
71
+ const userSpecs = readSettingsPackages(path.join(agentDir, "settings.json"));
72
+ const projectSpecs = readSettingsPackages(path.join(repoRoot, ".pi", "settings.json"));
73
+ const inventories = [];
74
+ if (userSpecs) {
75
+ inventories.push({
76
+ specs: userSpecs,
77
+ nodeModulesRoot: path.join(agentDir, "npm", "node_modules"),
78
+ });
79
+ }
80
+ if (projectSpecs) {
81
+ inventories.push({
82
+ specs: projectSpecs,
83
+ nodeModulesRoot: path.join(repoRoot, "node_modules"),
84
+ });
85
+ }
86
+ return inventories;
87
+ }
88
+ const EXTENSION_FILE_RE = /\.(?:ts|js|mjs|cjs)$/;
89
+ /**
90
+ * Resolve a package's `pi.extensions` entries to absolute entry paths.
91
+ * - A file entry maps to itself.
92
+ * - A directory entry expands to every extension file directly inside it
93
+ * (e.g. `@estebanforge/pi-codegraph-enhanced` ships `./extensions` holding
94
+ * `codegraph.ts`).
95
+ * No `pi.extensions` declaration, or a declaration whose paths do not exist,
96
+ * yields no entries (caller records `no-entry`) — no heuristic scanning
97
+ * outside declared paths.
98
+ */
99
+ export function resolvePackageEntryPaths(packageDir) {
100
+ let manifest;
101
+ try {
102
+ manifest = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8"));
103
+ }
104
+ catch {
105
+ return { entryPaths: [], reason: "no-entry" };
106
+ }
107
+ const declared = manifest.pi?.extensions;
108
+ if (!Array.isArray(declared)) {
109
+ return { entryPaths: [], reason: "no-entry" };
110
+ }
111
+ const entryPaths = [];
112
+ for (const entry of declared) {
113
+ if (typeof entry !== "string" || entry.length === 0)
114
+ continue;
115
+ const resolved = path.resolve(packageDir, entry);
116
+ if (!existsSync(resolved))
117
+ continue;
118
+ let isDirectory = false;
119
+ try {
120
+ isDirectory = statSync(resolved).isDirectory();
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (!isDirectory) {
126
+ entryPaths.push(resolved);
127
+ continue;
128
+ }
129
+ let names;
130
+ try {
131
+ names = readdirSync(resolved);
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ for (const name of names.sort()) {
137
+ if (!EXTENSION_FILE_RE.test(name))
138
+ continue;
139
+ entryPaths.push(path.join(resolved, name));
140
+ }
141
+ }
142
+ if (entryPaths.length === 0) {
143
+ return { entryPaths: [], reason: "no-entry" };
144
+ }
145
+ return { entryPaths };
146
+ }
147
+ /**
148
+ * Resolve frozen DAG short ids against the Pi settings package inventory
149
+ * (user `~/.pi/agent/settings.json`, then project `.pi/settings.json` — a
150
+ * later match on the same alias wins). Pure synchronous read; never throws.
151
+ */
152
+ export function resolveDagPiExtensions(input) {
153
+ const inventories = loadPackageInventories(input.repoRoot);
154
+ const resolved = [];
155
+ const missing = [];
156
+ for (const id of input.ids) {
157
+ if (!isDagPiExtensionId(id)) {
158
+ missing.push({
159
+ id,
160
+ reason: "package-unreadable",
161
+ detail: `unknown pi extension id: ${id}`,
162
+ });
163
+ continue;
164
+ }
165
+ const aliases = DAG_PI_EXTENSION_PACKAGE_ALIASES[id];
166
+ let matched;
167
+ // Later inventories (project) override earlier (user).
168
+ for (const inventory of inventories) {
169
+ for (const spec of inventory.specs) {
170
+ const packageName = packageNameFromSpec(spec);
171
+ if (!aliases.includes(packageName))
172
+ continue;
173
+ const packageDir = path.join(inventory.nodeModulesRoot, ...packageName.split("/"));
174
+ if (!existsSync(packageDir))
175
+ continue;
176
+ try {
177
+ const manifest = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8"));
178
+ if (typeof manifest.version !== "string")
179
+ continue;
180
+ matched = { packageDir, packageName, version: manifest.version };
181
+ }
182
+ catch {
183
+ // unreadable manifest: skip this candidate
184
+ }
185
+ }
186
+ }
187
+ if (!matched) {
188
+ missing.push({ id, reason: "not-installed" });
189
+ continue;
190
+ }
191
+ const entries = resolvePackageEntryPaths(matched.packageDir);
192
+ if (entries.entryPaths.length === 0) {
193
+ missing.push({
194
+ id,
195
+ reason: entries.reason === "no-entry" ? "no-entry" : "package-unreadable",
196
+ });
197
+ continue;
198
+ }
199
+ resolved.push({
200
+ id,
201
+ packageName: matched.packageName,
202
+ version: matched.version,
203
+ entryPaths: entries.entryPaths,
204
+ });
205
+ }
206
+ return { resolved, missing };
207
+ }
208
+ /**
209
+ * Compute extension tool names that may join a node's active tool allowlist
210
+ * after session creation (D5): new tool names registered by the loaded
211
+ * extensions, minus protected builtins, the deny surface, and write-capable
212
+ * extension tools (both buckets — writer writes stay policy-wrapped only).
213
+ */
214
+ export function computeExtensionToolAllowlistExtras(sessionTools, baseline) {
215
+ const baselineLower = new Set(baseline.map((name) => name.toLowerCase()));
216
+ const extras = [];
217
+ for (const tool of sessionTools) {
218
+ const name = tool && typeof tool.name === "string" ? tool.name : undefined;
219
+ if (!name)
220
+ continue;
221
+ const lower = name.toLowerCase();
222
+ if (baselineLower.has(lower))
223
+ continue;
224
+ if (DAG_PI_EXTENSION_PROTECTED_BUILTIN_TOOLS.has(lower))
225
+ continue;
226
+ if (DAG_PI_EXTENSION_DENIED_TOOL_IDS.has(lower))
227
+ continue;
228
+ if (DAG_PI_EXTENSION_WRITE_TOOL_DENY.has(lower))
229
+ continue;
230
+ extras.push(name);
231
+ }
232
+ return extras;
233
+ }
@@ -1,5 +1,6 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { computeExtensionToolAllowlistExtras } from "./pi-extension-resolver.js";
3
4
  import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractPiEventStopReason, extractAssistantTextFromPiJson, } from "./pi-executor.js";
4
5
  import { serializeSessionEvent } from "./pi-event-serializer.js";
5
6
  import { PI_RECOMMENDED_COMPACTION, PI_RECOMMENDED_RETRY, } from "../shared/pi-retry-settings.js";
@@ -229,6 +230,9 @@ async function createSdkSession(sdk, input, shared) {
229
230
  noContextFiles: true,
230
231
  noSkills: true,
231
232
  noExtensions: true,
233
+ ...(input.additionalExtensionPaths && input.additionalExtensionPaths.length > 0
234
+ ? { additionalExtensionPaths: input.additionalExtensionPaths }
235
+ : {}),
232
236
  ...(settingsManager ? { settingsManager } : {}),
233
237
  appendSystemPromptOverride: (base) => [
234
238
  ...base,
@@ -236,6 +240,19 @@ async function createSdkSession(sdk, input, shared) {
236
240
  ],
237
241
  });
238
242
  await loader.reload();
243
+ // Discover extension-registered tool names BEFORE session creation (plan
244
+ // 2026-08-21 D5): verified against the real SDK — passing extension tool
245
+ // names in createAgentSession `tools` activates them, while a post-create
246
+ // setActiveToolsByName pin leaves the active set unchanged. Merge filtered
247
+ // extras into the create-time allowlist; protected builtins, the deny
248
+ // surface, and write-capable extension tools never join.
249
+ const extensionToolExtras = [];
250
+ if (input.additionalExtensionPaths &&
251
+ input.additionalExtensionPaths.length > 0) {
252
+ const registered = collectLoaderExtensionToolNames(loader);
253
+ extensionToolExtras.push(...computeExtensionToolAllowlistExtras(registered.map((name) => ({ name })), input.toolNames));
254
+ }
255
+ const sessionToolNames = [...input.toolNames, ...extensionToolExtras];
239
256
  if (input.requireWriterCustomTools) {
240
257
  if (!Array.isArray(input.customTools) || input.customTools.length === 0) {
241
258
  throw new Error("pi writer tool policy missing customTools; refusing to create uncontrolled writer session");
@@ -245,7 +262,7 @@ async function createSdkSession(sdk, input, shared) {
245
262
  cwd: input.cwd,
246
263
  sessionManager: SessionManager.inMemory(input.cwd),
247
264
  resourceLoader: loader,
248
- tools: input.toolNames,
265
+ tools: sessionToolNames,
249
266
  modelRuntime,
250
267
  ...(settingsManager ? { settingsManager } : {}),
251
268
  ...(model ? { model } : {}),
@@ -254,6 +271,22 @@ async function createSdkSession(sdk, input, shared) {
254
271
  ? { customTools: input.customTools }
255
272
  : {}),
256
273
  });
274
+ if (extensionToolExtras.length > 0) {
275
+ // Fail closed when merged extension tools did not activate: a silent
276
+ // pin failure must never let the node run without its declared tools.
277
+ const activeTools = created.session.agent?.state?.tools;
278
+ if (!Array.isArray(activeTools)) {
279
+ throw new Error("pi SDK session does not expose agent.state.tools; refusing unverifiable extension-tool activation");
280
+ }
281
+ const activeNames = new Set(activeTools
282
+ .map((tool) => typeof tool?.name === "string" ? tool.name : undefined)
283
+ .filter((name) => Boolean(name)));
284
+ for (const extensionToolName of extensionToolExtras) {
285
+ if (!activeNames.has(extensionToolName)) {
286
+ throw new Error(`pi SDK extension-tool activation mismatch for ${extensionToolName}: tool did not activate at session creation`);
287
+ }
288
+ }
289
+ }
257
290
  const customToolNames = (input.customTools ?? [])
258
291
  .map((tool) => isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined)
259
292
  .filter((name) => Boolean(name));
@@ -266,7 +299,7 @@ async function createSdkSession(sdk, input, shared) {
266
299
  .map((tool) => typeof tool?.name === "string" ? tool.name : undefined)
267
300
  .filter((name) => Boolean(name)));
268
301
  for (const customToolName of customToolNames) {
269
- const expectedActive = input.toolNames.includes(customToolName);
302
+ const expectedActive = sessionToolNames.includes(customToolName);
270
303
  if (activeNames.has(customToolName) !== expectedActive) {
271
304
  throw new Error(`pi SDK custom-tool activation mismatch for ${customToolName}: expectedActive=${expectedActive}`);
272
305
  }
@@ -274,6 +307,47 @@ async function createSdkSession(sdk, input, shared) {
274
307
  }
275
308
  return created.session;
276
309
  }
310
+ /**
311
+ * Collect tool names registered by loader-resolved extensions. The SDK exposes
312
+ * each extension's `tools` as a Record (name → definition) or Map; both shapes
313
+ * are handled. Missing getExtensions (older SDK) yields no extras — the node
314
+ * then runs with the baseline allowlist only.
315
+ */
316
+ function collectLoaderExtensionToolNames(loader) {
317
+ const getExtensions = loader?.getExtensions;
318
+ if (typeof getExtensions !== "function")
319
+ return [];
320
+ let parsed;
321
+ try {
322
+ parsed = getExtensions.call(loader);
323
+ }
324
+ catch {
325
+ return [];
326
+ }
327
+ if (!parsed || typeof parsed !== "object")
328
+ return [];
329
+ const extensions = parsed.extensions;
330
+ if (!Array.isArray(extensions))
331
+ return [];
332
+ const names = [];
333
+ for (const extension of extensions) {
334
+ if (!extension || typeof extension !== "object")
335
+ continue;
336
+ const tools = extension.tools;
337
+ if (tools instanceof Map) {
338
+ for (const key of tools.keys()) {
339
+ if (typeof key === "string")
340
+ names.push(key);
341
+ }
342
+ }
343
+ else if (tools && typeof tools === "object") {
344
+ for (const key of Object.keys(tools)) {
345
+ names.push(key);
346
+ }
347
+ }
348
+ }
349
+ return names;
350
+ }
277
351
  /**
278
352
  * Throttle high-noise Pi SDK events from session-events.jsonl.
279
353
  * Always keeps tool lifecycle, turn/agent end, assistant messages, and errors.
@@ -545,6 +619,9 @@ export async function executeSingleSdkAttempt(options) {
545
619
  ? { customTools: writerCustomTools }
546
620
  : {}),
547
621
  ...(requireWriterCustomTools ? { requireWriterCustomTools: true } : {}),
622
+ ...(options.piExtensionPaths && options.piExtensionPaths.length > 0
623
+ ? { additionalExtensionPaths: options.piExtensionPaths }
624
+ : {}),
548
625
  });
549
626
  let resolveStall;
550
627
  let resolveSettlement;
@@ -123,6 +123,39 @@ function shouldReuseSuccessfulShellCommands(shell) {
123
123
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
124
124
  const SUMMARY_STDOUT_MAX = 4_000;
125
125
  const SUMMARY_STDERR_MAX = 2_000;
126
+ const TEST_TIMEOUT_CLASSIFY_MAX_CHARS = 8_000;
127
+ const TEST_TIMEOUT_MARKERS = [
128
+ /Test timed out in \d+ms/i,
129
+ /Timeout of \d+ms exceeded/i,
130
+ ];
131
+ const DETERMINISTIC_NON_TIMEOUT_FAILURE_MARKERS = [
132
+ /AssertionError/i,
133
+ /expected:?\s/i,
134
+ /received:?\s/i,
135
+ /Snapshot /i,
136
+ /toMatchSnapshot/i,
137
+ /SyntaxError/i,
138
+ /TS\d{4}/,
139
+ /error TS/i,
140
+ /eslint/i,
141
+ /compile error/i,
142
+ /Failed to compile/i,
143
+ ];
144
+ function isIdentifiedTestCommand(command) {
145
+ return /(?:^|[\s"'`])(?:npx\s+)?vitest(?:\s|$)|(?:^|[\s"'`])npm(?:\.cmd)?\s+test(?:\s|$)|(?:^|[\s"'`])pnpm(?:\.cmd)?\s+test(?:\s|$)|(?:^|[\s"'`])yarn(?:\.cmd)?\s+test(?:\s|$)/i.test(command);
146
+ }
147
+ function classifyShellCommandFailure(input) {
148
+ if (input.processCategory !== "nonzero-exit")
149
+ return input.processCategory;
150
+ if (!isIdentifiedTestCommand(input.command))
151
+ return "nonzero-exit";
152
+ const sample = `${input.stdout}\n${input.stderr}`.slice(0, TEST_TIMEOUT_CLASSIFY_MAX_CHARS);
153
+ const hasTimeoutMarker = TEST_TIMEOUT_MARKERS.some((marker) => marker.test(sample));
154
+ if (!hasTimeoutMarker)
155
+ return "nonzero-exit";
156
+ const mixedDeterministicFailure = DETERMINISTIC_NON_TIMEOUT_FAILURE_MARKERS.some((marker) => marker.test(sample));
157
+ return mixedDeterministicFailure ? "nonzero-exit" : "test-timeout";
158
+ }
126
159
  const NODE_STDOUT_MAX = 12_000;
127
160
  const NODE_STDERR_MAX = 8_000;
128
161
  const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
@@ -329,16 +362,21 @@ export async function executeShellCommand(input) {
329
362
  });
330
363
  });
331
364
  child.on("close", (code) => {
332
- let failureCategory = "nonzero-exit";
365
+ let processCategory = "nonzero-exit";
333
366
  if (timedOut) {
334
- failureCategory = "timeout";
367
+ processCategory = "timeout";
335
368
  }
336
369
  else if (code === 0) {
337
- failureCategory = "success";
370
+ processCategory = "success";
338
371
  }
339
372
  finish({
340
373
  exitCode: code,
341
- failureCategory,
374
+ failureCategory: classifyShellCommandFailure({
375
+ command: input.command,
376
+ processCategory,
377
+ stdout: formatBoundedOutput(stdout),
378
+ stderr: formatBoundedOutput(stderr),
379
+ }),
342
380
  ok: code === 0 && !timedOut,
343
381
  });
344
382
  });
@@ -2878,6 +2916,7 @@ export async function executeDagShellNode(input, meta) {
2878
2916
  const cwd = resolveShellCwd(input.cwd, shell.cwd);
2879
2917
  const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
2880
2918
  const failFast = shell.failFast !== false;
2919
+ const attemptNumber = input.attempt ?? 1;
2881
2920
  const results = [];
2882
2921
  const executedCommandKeys = new Set();
2883
2922
  let beforeStatus;
@@ -2973,8 +3012,8 @@ export async function executeDagShellNode(input, meta) {
2973
3012
  dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
2974
3013
  reportActivity: input.reportActivity,
2975
3014
  outputArtifacts: {
2976
- stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
2977
- stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
3015
+ stdoutPath: path.join(meta.runDir, input.task.id, `attempt-${attemptNumber}`, "commands", `${commandNumber}.stdout.txt`),
3016
+ stderrPath: path.join(meta.runDir, input.task.id, `attempt-${attemptNumber}`, "commands", `${commandNumber}.stderr.txt`),
2978
3017
  },
2979
3018
  });
2980
3019
  results.push(result);
@@ -3033,6 +3072,7 @@ export async function executeDagShellNode(input, meta) {
3033
3072
  schemaVersion: 1,
3034
3073
  runId: meta.runId,
3035
3074
  nodeId: input.task.id,
3075
+ attempt: attemptNumber,
3036
3076
  workspaceFingerprint: workspaceFingerprint ?? null,
3037
3077
  failFast,
3038
3078
  commands: results.map((result) => ({
@@ -3051,6 +3091,7 @@ export async function executeDagShellNode(input, meta) {
3051
3091
  subchecks: parseCheckRepoSubcheckDurations(result),
3052
3092
  })),
3053
3093
  };
3094
+ await writeDagNodeJsonArtifact(meta.runDir, input.task.id, path.posix.join(`attempt-${attemptNumber}`, "shell-command-receipt.json"), receipt);
3054
3095
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "shell-command-receipt.json", receipt);
3055
3096
  const summaryMarkdown = buildShellResultSummaryMarkdown({
3056
3097
  nodeId: input.task.id,
@@ -3102,6 +3143,9 @@ export async function executeDagShellNode(input, meta) {
3102
3143
  stdout: `${recordPrefix}${reuseNote}${aggregate.stdout}`,
3103
3144
  stderr: stderrParts.filter(Boolean).join("\n\n"),
3104
3145
  failureCategory,
3146
+ ...(firstFailure?.failureCategory
3147
+ ? { rawFailureCategory: firstFailure.failureCategory }
3148
+ : {}),
3105
3149
  durationMs: Date.now() - started,
3106
3150
  // extra field consumed by node-execution if supported; also embedded in stdout marker
3107
3151
  ...{ commandResults },
@@ -98,6 +98,13 @@ export function isEphemeralToolCachePath(filePath) {
98
98
  const normalized = normalizePath(filePath);
99
99
  if (!normalized)
100
100
  return false;
101
+ // CodeGraph extension sync side effect (plan 2026-08-21 D7): a loaded
102
+ // pi-codegraph extension keeps an existing .codegraph/ index fresh at
103
+ // session start; those index files are extension-owned run state, not
104
+ // writer output, so they never count as write-guard violations.
105
+ if (normalized === ".codegraph" || normalized.startsWith(".codegraph/")) {
106
+ return true;
107
+ }
101
108
  if (normalized === ".pytest_cache" ||
102
109
  normalized.startsWith(".pytest_cache/")) {
103
110
  return true;
@@ -1822,6 +1822,13 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
1822
1822
  source: "loop-agent",
1823
1823
  exclusionReason: "Not exposed to Operator Chat in M5.",
1824
1824
  },
1825
+ {
1826
+ command: "loop-agent init model-catalog",
1827
+ coverage: "excluded",
1828
+ action: null,
1829
+ source: "loop-agent",
1830
+ exclusionReason: "Not exposed to Operator Chat in M5.",
1831
+ },
1825
1832
  {
1826
1833
  command: "loop-agent init doctor",
1827
1834
  coverage: "excluded",