@tea-agent/loop-agent 0.41.0 → 0.42.0-next.1

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 (162) hide show
  1. package/CHANGELOG.md +102 -14
  2. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  3. package/dist/adapters/context-transfer/pi-session.js +61 -0
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/command-definitions.js +12 -0
  6. package/dist/cli/program.js +23 -1
  7. package/dist/commands/dag-artifact.js +284 -0
  8. package/dist/commands/dag-context.js +184 -0
  9. package/dist/commands/dag-rerun.js +206 -1
  10. package/dist/executors/dag-pi-executor.js +39 -12
  11. package/dist/executors/pi-sdk-executor.js +3 -0
  12. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  13. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  14. package/dist/infrastructure/console/context-export-store.js +160 -0
  15. package/dist/infrastructure/console/dir-lock.js +132 -0
  16. package/dist/{worker → infrastructure}/console/operation-store.js +28 -0
  17. package/dist/infrastructure/harness/artifact-store.js +10 -1
  18. package/dist/infrastructure/harness/atomic-write.js +12 -2
  19. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  20. package/dist/shared/context-transfer.js +418 -0
  21. package/dist/shared/operator/capabilities.js +14 -0
  22. package/dist/shared/operator/safe-run-summary.js +1 -0
  23. package/dist/shared/path-safety.js +93 -0
  24. package/dist/shared/preview.js +28 -4
  25. package/dist/task/config-types.js +2 -1
  26. package/dist/task/task-demand-routing.js +3 -0
  27. package/dist/worker/console/chat/chat-event-store.js +2 -2
  28. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  29. package/dist/worker/console/chat/routes.js +1 -1
  30. package/dist/worker/console/chat/session-store.js +1 -1
  31. package/dist/worker/console/chat/user-questions.js +1 -1
  32. package/dist/worker/console/console-update-runtime.js +1 -1
  33. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  34. package/dist/worker/console/dag-confirmation.js +1 -1
  35. package/dist/worker/console/dag-execution-receipt.js +1 -1
  36. package/dist/worker/console/doctor.js +1 -1
  37. package/dist/worker/console/draft-store.js +1 -1
  38. package/dist/worker/console/human-gate-token.js +1 -1
  39. package/dist/worker/console/index.js +3 -3
  40. package/dist/worker/console/interview/assessment.js +1 -1
  41. package/dist/worker/console/interview/session.js +1 -1
  42. package/dist/worker/console/operation-runner.js +45 -4
  43. package/dist/worker/console/operation-sse.js +1 -1
  44. package/dist/worker/console/operation-wait.js +1 -1
  45. package/dist/worker/console/operator-actions.js +104 -3
  46. package/dist/worker/console/operator-surface-health.js +1 -1
  47. package/dist/worker/console/operator-user-error.js +169 -0
  48. package/dist/worker/console/pi-plugins.js +57 -0
  49. package/dist/worker/console/routes.js +637 -8
  50. package/dist/worker/console/security.js +35 -0
  51. package/dist/worker/console/server.js +6 -3
  52. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DwVJxUxz.js → abnfDiagram-N423BO3Z-DZZ8m3PO.js} +1 -1
  53. package/dist/worker/console/static/assets/{arc-DqfCcXdu.js → arc-D6PvaVd-.js} +1 -1
  54. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-qMVtggRv.js → architectureDiagram-T3A2C74G-B_OTOiI8.js} +1 -1
  55. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-ECrV38dh.js → blockDiagram-VBNYF7ZC-Bv6rqHBg.js} +1 -1
  56. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DBaaLqQv.js → c4Diagram-5PPSVZJV-B8eHr0oz.js} +1 -1
  57. package/dist/worker/console/static/assets/channel-BU5gOilw.js +1 -0
  58. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BvfNHkAz.js → chunk-2GRJ4B5K-DYwR0im2.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-C5StiIbc.js → chunk-2Q5K7J3B-D2WPGqXt.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-HabGiJkS.js → chunk-5RXB4S5H-CQISJ_I7.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BiTg127i.js → chunk-5VM5RSS4-C0o2Du1e.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CZsXBPuR.js → chunk-6Q2QTUOP-4f8kr-U7.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D-r1MxLK.js → chunk-GF5L2VYU-D_OWvXzX.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DQxb9plr.js → chunk-JWPE2WC7-CasdPz5X.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMndGvCT.js → chunk-KBJHAD2P-Cj8lRrla.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ByL4BgOJ.js → chunk-RYQCIY6F-CUvD1FSd.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BVe1b1gL.js → chunk-XXDRQBXY-CqLw_eqB.js} +1 -1
  68. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +1 -0
  69. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +1 -0
  70. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BAyGzN8j.js → cose-bilkent-JH36ORCC-ku-WqJPx.js} +1 -1
  71. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-C5yVYU7E.js → cynefin-VYW2F7L2-Bq_PYDhl.js} +1 -1
  72. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-x-MwsEm9.js → cynefinDiagram-MW4NZA55-BiGZV641.js} +1 -1
  73. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CT5Tle5F.js → dagre-VZM6K2ZE-C4JRlglF.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BARERhlS.js → diagram-7IWD3JNH-B0dNeWYG.js} +1 -1
  75. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BwfUz0VM.js → diagram-B4RE2ZJO-Cj35YvYM.js} +1 -1
  76. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Mk4r2WvJ.js → diagram-LBJQPF4R-uzQoJ2-8.js} +1 -1
  77. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-KUrxuBs0.js → diagram-Q27KOJAE-D1a-Buoz.js} +1 -1
  78. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CjZOceZn.js → diagram-UB23O5K3-XjRrLRSs.js} +1 -1
  79. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-rnwUCiEI.js → ebnfDiagram-BXEA7PRR-Da_O24cW.js} +1 -1
  80. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DM4j23FM.js → erDiagram-JOGREHBK-BLJ8jrYU.js} +1 -1
  81. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DD5HdDEs.js → flowDiagram-UKHOOZJN-B9GrLjM0.js} +1 -1
  82. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CIAGSF47.js → ganttDiagram-PKOTCBZU-CeJ0TqiK.js} +1 -1
  83. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DbwFbVEL.js → gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js} +1 -1
  84. package/dist/worker/console/static/assets/index-H9rFJiGL.css +1 -0
  85. package/dist/worker/console/static/assets/{index-Bih9w9h9.js → index-xwu9GxEc.js} +80 -80
  86. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-B-Pb0T66.js → infoDiagram-6WML65LV-DB26i3d8.js} +1 -1
  87. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B9Unk-YX.js → ishikawaDiagram-WSZJBQD7-BOGRyeZT.js} +1 -1
  88. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C2F3JkPt.js → journeyDiagram-NVQOT4AX-CZRoBO_6.js} +1 -1
  89. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Bl8-Xcoz.js → kanban-definition-27J2QSJJ-qlWhJyoB.js} +1 -1
  90. package/dist/worker/console/static/assets/{linear-yK4uZmC5.js → linear-CfUiDDB3.js} +1 -1
  91. package/dist/worker/console/static/assets/{mermaid.core-Bpa6PmuE.js → mermaid.core-BQe6fpqj.js} +5 -5
  92. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BfGRoCSe.js → mindmap-definition-FAOFIHXS-zFzWHw64.js} +1 -1
  93. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bz6_HtST.js → pegDiagram-VL7TDLO6-DFGUqjZO.js} +1 -1
  94. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-i0Umbsby.js → pieDiagram-7S7Q4E2Y-BB5i0l8Z.js} +1 -1
  95. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DaVQBobe.js → quadrantDiagram-CIZ2JOQS-obbVZ_X5.js} +1 -1
  96. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DMTGnSq7.js → railroadDiagram-AXF67PYL-BwdaNzc1.js} +1 -1
  97. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-2ao_DaSq.js → requirementDiagram-LRYGKXZP-CkTJaugh.js} +1 -1
  98. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-2SG1lTB9.js → sankeyDiagram-W5VNT64P-BwJ-hIgq.js} +1 -1
  99. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BQYOLybR.js → sequenceDiagram-SI44F4Z6-BzSgmm7w.js} +1 -1
  100. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-C8g3yjuI.js → sizeCapture-X5ZJPWSS-Bn3oj5Q1.js} +1 -1
  101. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-q_F8nAQG.js → stateDiagram-OKZ733FA-dxhGtL8K.js} +1 -1
  102. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +1 -0
  103. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-8LrWpH2s.js → swimlanes-SLNWSIFB-BLpEInya.js} +2 -2
  104. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +8 -0
  105. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BQoY3qzl.js → timeline-definition-Z64GVDOM-ChJGYcXy.js} +1 -1
  106. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BDu1UjSp.js → vennDiagram-T6HMQDX7-DK-qjRer.js} +1 -1
  107. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C48M7Y0a.js → wardleyDiagram-T6FBY63Y-3qeaWAg-.js} +1 -1
  108. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YU6FTl_F.js → xychartDiagram-ELKLHX3M-CootlyP9.js} +1 -1
  109. package/dist/worker/console/static/index.html +7 -2
  110. package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
  111. package/dist/worker/console/static-src/shell/console-update-reload.js +25 -0
  112. package/dist/worker/console/workspace-context.js +28 -2
  113. package/dist/worker/console/workspace-registry.js +2 -2
  114. package/dist/worker/continuation/worker-continuation.js +278 -0
  115. package/dist/worker/observe/health.js +1 -1
  116. package/dist/worker/observe/node-transparency.js +572 -0
  117. package/dist/worker/observe/routes.js +51 -1
  118. package/dist/worker/observe/static/api.js +69 -5
  119. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  120. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  121. package/dist/worker/observe/static/state.js +3 -1
  122. package/dist/worker/observe/static/styles.css +1037 -126
  123. package/dist/worker/observe/static/views/dag-inspector.js +1215 -58
  124. package/dist/worker/observe/static/views/session-timeline.js +75 -10
  125. package/dist/worker/outcomes/adapters.js +19 -0
  126. package/dist/worker/outcomes/types.js +1 -0
  127. package/dist/worker/pool/attempt-lease.js +97 -66
  128. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  129. package/dist/worker/pool/reconcile.js +46 -1
  130. package/dist/worker/pool/run-store.js +2 -0
  131. package/dist/worker/pool/state-projection.js +2 -0
  132. package/dist/worker/task-spec/workflow-routing.js +8 -3
  133. package/dist/workflows/dag/artifact-bindings.js +149 -0
  134. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  135. package/dist/workflows/dag/budget-enforcement.js +10 -0
  136. package/dist/workflows/dag/context-receipt.js +305 -0
  137. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  138. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  139. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  140. package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
  141. package/dist/workflows/dag/init-hybrid.js +146 -0
  142. package/dist/workflows/dag/node-execution.js +112 -27
  143. package/dist/workflows/dag/path-safety.js +1 -0
  144. package/dist/workflows/dag/prompt.js +29 -4
  145. package/dist/workflows/dag/rerun-plan.js +555 -1
  146. package/dist/workflows/dag/rerun-run.js +601 -15
  147. package/dist/workflows/dag/runner.js +20 -1
  148. package/dist/workflows/dag/skill-snapshot.js +17 -0
  149. package/dist/workflows/dag/types.js +183 -7
  150. package/docs/architecture/runtime-boundaries.md +6 -6
  151. package/docs/architecture/worker-and-feature.md +1 -1
  152. package/docs/templates/agent-dag.schema.json +32 -0
  153. package/docs/templates/product-line/task.yaml +1 -1
  154. package/package.json +3 -1
  155. package/skills/loop-agent/references/command-reference.md +6 -0
  156. package/dist/worker/console/static/assets/channel-CL1NRAmS.js +0 -1
  157. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-PBGzzjZA.js +0 -1
  158. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-PBGzzjZA.js +0 -1
  159. package/dist/worker/console/static/assets/index-BfleX-qj.css +0 -1
  160. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrxGBDJu.js +0 -1
  161. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BhpDxzAf.js +0 -8
  162. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -13,8 +13,9 @@ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./w
13
13
  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";
14
14
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
15
15
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
16
- import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
16
+ import { frontendTestLayoutFromSpec } from "../workflows/dag/frontend-test-layout.js";
17
17
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
18
+ import { writeEffectiveContextReceipt } from "../workflows/dag/context-receipt.js";
18
19
  /**
19
20
  * Writer classification for a length-stopped thinking-only attempt: the model
20
21
  * exhausted its output budget thinking but never issued a write/edit tool call
@@ -516,7 +517,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
516
517
  let playwrightToolContext;
517
518
  if (allowsPlaywrightCli && !isWriteTask) {
518
519
  return {
519
- ok: false, stdout: "", stderr: "pi command capability requires an explicit bounded write tool profile", failureCategory: "tool-policy", durationMs: Date.now() - started,
520
+ ok: false,
521
+ stdout: "",
522
+ stderr: "pi command capability requires an explicit bounded write tool profile",
523
+ failureCategory: "tool-policy",
524
+ durationMs: Date.now() - started,
520
525
  };
521
526
  }
522
527
  const strictReadSet = (input.task.readSet?.length ?? 0) > 0;
@@ -642,14 +647,34 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
642
647
  const piExtensionPaths = piExtensionsResolution && resolvePiBackend() !== "cli-only"
643
648
  ? piExtensionsResolution.resolved.flatMap((entry) => entry.entryPaths)
644
649
  : undefined;
650
+ const modelConfig = resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined);
651
+ const toolNames = resolveDagPiToolNames(input.task);
652
+ const userMessage = buildDagPiUserMessage(input.task, persona, step);
653
+ await writeEffectiveContextReceipt({
654
+ runDir: meta.runDir,
655
+ runId: meta.runId,
656
+ nodeId: input.task.id,
657
+ attempt,
658
+ task: input.task,
659
+ spec: meta.spec,
660
+ repoRoot: input.cwd,
661
+ systemPrompt: input.prompt,
662
+ userMessage,
663
+ resolvedModel: `${modelConfig.provider}/${modelConfig.model}`,
664
+ resolvedSkills: input.resolvedSkills ?? [],
665
+ resolvedExtensions: piExtensionsResolution && resolvePiBackend() !== "cli-only"
666
+ ? piExtensionsResolution.resolved.map((entry) => entry.id)
667
+ : [],
668
+ activeTools: toolNames,
669
+ });
645
670
  result = await piStepFn({
646
671
  attachedFiles: [],
647
- modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
672
+ modelConfig,
648
673
  prompt: input.prompt,
649
674
  repoRoot: input.cwd,
650
675
  step,
651
- toolNames: resolveDagPiToolNames(input.task),
652
- userMessage: buildDagPiUserMessage(input.task, persona, step),
676
+ toolNames,
677
+ userMessage,
653
678
  sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
654
679
  // Absolute max wall clock from adaptive liveness policy (defaults to 4h).
655
680
  timeoutMs: input.timeoutMs,
@@ -927,8 +952,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
927
952
  });
928
953
  }
929
954
  let completenessFailure;
930
- if (writeGuardOk &&
931
- isBackendTestCompletenessRetryCandidate(input.task)) {
955
+ if (writeGuardOk && isBackendTestCompletenessRetryCandidate(input.task)) {
932
956
  try {
933
957
  const writerRole = backendTestWriterProgressRoleForTask(input.task.id);
934
958
  const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
@@ -941,7 +965,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
941
965
  const progress = isBackendTestShardChildTask(input.task)
942
966
  ? await assessBackendTestShardChildCompleteness({
943
967
  workspaceRoot: input.cwd,
944
- role: writerRole === "pytest-generate" ? "pytest-generate" : "md-generate",
968
+ role: writerRole === "pytest-generate"
969
+ ? "pytest-generate"
970
+ : "md-generate",
945
971
  writeSet: input.task.writeSet ?? [],
946
972
  })
947
973
  : isBackendTestMdPlanTask(input.task)
@@ -987,7 +1013,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
987
1013
  };
988
1014
  }
989
1015
  }
990
- if (mapped.ok && writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
1016
+ if (mapped.ok &&
1017
+ writeGuardOk &&
1018
+ !writerOutcomeViolation &&
1019
+ !completenessFailure) {
991
1020
  return mapped;
992
1021
  }
993
1022
  const writerThinkingExhausted = isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles);
@@ -1393,9 +1422,7 @@ async function persistWriterChangeManifest(input) {
1393
1422
  ...(input.approvalSourceNodeId
1394
1423
  ? { approvalSourceNodeId: input.approvalSourceNodeId }
1395
1424
  : {}),
1396
- ...(input.approvalDigest
1397
- ? { approvalDigest: input.approvalDigest }
1398
- : {}),
1425
+ ...(input.approvalDigest ? { approvalDigest: input.approvalDigest } : {}),
1399
1426
  changedFiles: input.changedFiles,
1400
1427
  beforeStatusSha256: createHash("sha256")
1401
1428
  .update(input.beforeStatus)
@@ -215,6 +215,9 @@ async function createSdkSession(sdk, input, shared) {
215
215
  const model = input.provider && input.model
216
216
  ? await resolveModel(modelRuntime, input.provider, input.model)
217
217
  : undefined;
218
+ if (input.provider && input.model && !model) {
219
+ throw new Error(`pi SDK model unavailable: ${input.provider}/${input.model}; refusing to fall back to the session default`);
220
+ }
218
221
  // Inject rate-limit-tolerant auto-retry settings for the session lifecycle.
219
222
  // Prefer SettingsManager.inMemory so harness runs do not depend on ~/.pi or
220
223
  // project .pi/settings.json being present/trusted.
@@ -35,6 +35,8 @@ export function openConsoleAppData(options) {
35
35
  fingerprint,
36
36
  drafts: path.join(repoNamespace, "drafts"),
37
37
  operations: path.join(repoNamespace, "operations"),
38
+ contextExports: path.join(repoNamespace, "context-exports"),
39
+ artifactRevisions: path.join(repoNamespace, "artifact-revisions"),
38
40
  confirmations: path.join(repoNamespace, "confirmations"),
39
41
  dagReceipts: path.join(repoNamespace, "dag-receipts"),
40
42
  staged: path.join(repoNamespace, "staged"),
@@ -48,6 +50,8 @@ export function openConsoleAppData(options) {
48
50
  for (const dir of [
49
51
  paths.drafts,
50
52
  paths.operations,
53
+ paths.contextExports,
54
+ paths.artifactRevisions,
51
55
  paths.confirmations,
52
56
  paths.dagReceipts,
53
57
  paths.staged,
@@ -0,0 +1,430 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstat, readdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { validateAndDescribeArtifactRevision, } from "../../shared/context-transfer/artifact-revision.js";
5
+ import { artifactRevisionV1Schema, computeOverrideSetHash, } from "../../shared/context-transfer.js";
6
+ import { assertSafeAppDataPath, ensureSecureDir, readJsonIfExists, writeSecureJson, } from "./app-data.js";
7
+ import { acquireDirLock, isDirLockStale, } from "./dir-lock.js";
8
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
9
+ export const DEFAULT_ARTIFACT_REVISION_TTL_MS = 60 * 60 * 1000;
10
+ /** Per-repo staging quota across uploads + staged candidates (M4b). */
11
+ export const ARTIFACT_REVISION_STAGING_QUOTA_BYTES = 200 * 1024 * 1024;
12
+ export const DEFAULT_REVISION_LEASE_TTL_MS = 5 * 60 * 1000;
13
+ /**
14
+ * Execute holds the revision across an entire child run (potentially hours);
15
+ * align with the Console default operation timeout instead of the short
16
+ * staging TTL.
17
+ */
18
+ export const EXECUTE_REVISION_LEASE_TTL_MS = 4 * 60 * 60 * 1000;
19
+ const STAGING_LOCK_DIR = ".staging-lock";
20
+ const STAGING_LOCK_STALE_MS = 30_000;
21
+ function stagingLockDirPath(appData) {
22
+ return path.join(root(appData), STAGING_LOCK_DIR);
23
+ }
24
+ function revisionMutationLockDirPath(appData, revisionId) {
25
+ if (!/^[a-f0-9]{32}$/.test(revisionId))
26
+ throw new Error("invalid revision id");
27
+ return assertSafeAppDataPath(root(appData), path.join(root(appData), "locks", `${revisionId}.lock`));
28
+ }
29
+ /**
30
+ * Cross-process repo-wide mutex around the quota "measure → decide → write"
31
+ * window. In-process promise chains alone leave a TOCTOU window between the
32
+ * Console upload route and sibling CLI stage processes; a stale-tolerant
33
+ * exclusive directory lock closes it.
34
+ *
35
+ * Ownership is token-guarded: each holder writes a unique owner file inside
36
+ * the lock directory and only ever removes a lock whose owner token matches
37
+ * its own. Release verifies owner + freshness BEFORE touching the path (B2):
38
+ * a stale lock is never moved by its former holder, so an old holder cannot
39
+ * race a successor's takeover and a third contender cannot acquire while the
40
+ * new holder is still in its critical section.
41
+ */
42
+ export async function acquireRepoStagingLock(appData, options) {
43
+ return acquireDirLock(stagingLockDirPath(appData), {
44
+ staleMs: options?.staleMs ?? STAGING_LOCK_STALE_MS,
45
+ token: options?.token,
46
+ });
47
+ }
48
+ async function withRepoStagingLock(appData, operation) {
49
+ const handle = await acquireRepoStagingLock(appData);
50
+ try {
51
+ return await operation();
52
+ }
53
+ finally {
54
+ await handle.release();
55
+ }
56
+ }
57
+ /**
58
+ * Cross-process per-revision serialization of metadata read-modify-write
59
+ * (B1). `serializeRevision` only serializes within one process; the Console
60
+ * and sibling CLI processes are separate processes that can both read
61
+ * metadata.json, mutate it, and write it back — losing an update (e.g. two
62
+ * executes, or an execute racing a discard, both "taking over" the lease).
63
+ * A per-revision directory lock held across the whole read-modify-write
64
+ * closes that window.
65
+ */
66
+ async function withRevisionMutationLock(appData, revisionId, operation) {
67
+ const lockDir = revisionMutationLockDirPath(appData, revisionId);
68
+ const handle = await acquireDirLock(lockDir);
69
+ try {
70
+ return await operation();
71
+ }
72
+ finally {
73
+ await handle.release();
74
+ }
75
+ }
76
+ function root(appData) {
77
+ if (!appData.artifactRevisions)
78
+ throw new Error("artifact revision root unavailable");
79
+ return assertSafeAppDataPath(appData.root, appData.artifactRevisions);
80
+ }
81
+ function dir(appData, revisionId) {
82
+ if (!/^[a-f0-9]{32}$/.test(revisionId))
83
+ throw new Error("invalid revision id");
84
+ return assertSafeAppDataPath(root(appData), path.join(root(appData), revisionId));
85
+ }
86
+ function metadataPath(appData, revisionId) {
87
+ return path.join(dir(appData, revisionId), "metadata.json");
88
+ }
89
+ export function artifactRevisionUploadPath(appData, candidateSha256) {
90
+ if (!/^[a-f0-9]{64}$/.test(candidateSha256)) {
91
+ throw new Error("invalid candidate upload hash");
92
+ }
93
+ const uploadsDir = assertSafeAppDataPath(root(appData), path.join(root(appData), "uploads"));
94
+ return assertSafeAppDataPath(uploadsDir, path.join(uploadsDir, `${candidateSha256}.bin`));
95
+ }
96
+ export async function totalArtifactRevisionStagingBytes(appData) {
97
+ let total = 0;
98
+ const stagingRoot = root(appData);
99
+ async function addFile(target) {
100
+ const stat = await lstat(target).catch(() => undefined);
101
+ if (stat?.isFile())
102
+ total += stat.size;
103
+ }
104
+ const uploadsDir = path.join(stagingRoot, "uploads");
105
+ const uploadsStat = await lstat(uploadsDir).catch(() => undefined);
106
+ if (uploadsStat?.isDirectory()) {
107
+ for (const entry of await readdir(uploadsDir)) {
108
+ if (!/^[a-f0-9]{64}\.bin$/.test(entry))
109
+ continue;
110
+ await addFile(path.join(uploadsDir, entry));
111
+ }
112
+ }
113
+ for (const name of await readdir(stagingRoot)) {
114
+ if (!/^[a-f0-9]{32}$/.test(name))
115
+ continue;
116
+ await addFile(path.join(stagingRoot, name, "candidate.bin"));
117
+ }
118
+ return total;
119
+ }
120
+ function assertStagingQuota(input) {
121
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
122
+ if (!Number.isSafeInteger(quota) || quota < 1)
123
+ throw new Error("invalid staging quota");
124
+ if (input.incomingBytes > quota) {
125
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: candidate exceeds staging quota");
126
+ }
127
+ }
128
+ export async function writeArtifactRevisionUpload(input) {
129
+ return withRepoStagingLock(input.appData, async () => {
130
+ const actual = createHash("sha256").update(input.candidateBytes).digest("hex");
131
+ if (actual !== input.candidateSha256) {
132
+ throw new Error("ARTIFACT_REVISION_DRIFT: candidate hash mismatch");
133
+ }
134
+ const uploadPath = artifactRevisionUploadPath(input.appData, input.candidateSha256);
135
+ // Idempotent upload (SF6): if the exact candidate already exists in the
136
+ // uploads dir, a retry of the same request must not consume quota again —
137
+ // the bytes are already accounted for. This prevents a spurious quota
138
+ // error when an idempotent retry arrives near the staging limit.
139
+ const existing = await lstat(uploadPath).catch(() => undefined);
140
+ if (existing) {
141
+ const existingBytes = await readFileBoundarySafe({
142
+ boundaryRoot: path.dirname(uploadPath),
143
+ targetPath: uploadPath,
144
+ label: "artifact revision upload",
145
+ });
146
+ if (createHash("sha256").update(existingBytes).digest("hex") !== input.candidateSha256) {
147
+ throw new Error("ARTIFACT_REVISION_DRIFT: existing staged upload hash mismatch");
148
+ }
149
+ return uploadPath;
150
+ }
151
+ assertStagingQuota({
152
+ appData: input.appData,
153
+ incomingBytes: input.candidateBytes.byteLength,
154
+ quotaBytes: input.quotaBytes,
155
+ });
156
+ const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
157
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
158
+ if (stagedBytes + input.candidateBytes.byteLength > quota) {
159
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
160
+ }
161
+ ensureSecureDir(path.dirname(uploadPath));
162
+ await writeFile(uploadPath, input.candidateBytes, { flag: "wx", mode: 0o600 });
163
+ return uploadPath;
164
+ });
165
+ }
166
+ export async function createArtifactRevision(input) {
167
+ return withRepoStagingLock(input.appData, async () => {
168
+ const revisionId = randomBytes(16).toString("hex");
169
+ const revisionDir = dir(input.appData, revisionId);
170
+ ensureSecureDir(revisionDir);
171
+ const now = input.now ?? new Date();
172
+ const ttlMs = input.ttlMs ?? DEFAULT_ARTIFACT_REVISION_TTL_MS;
173
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
174
+ throw new Error("invalid revision TTL");
175
+ assertStagingQuota({
176
+ appData: input.appData,
177
+ incomingBytes: input.candidateBytes.byteLength,
178
+ quotaBytes: input.quotaBytes,
179
+ });
180
+ const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
181
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
182
+ if (stagedBytes + input.candidateBytes.byteLength > quota) {
183
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
184
+ }
185
+ const stagingPath = `artifact-revisions/${revisionId}/candidate.bin`;
186
+ const described = validateAndDescribeArtifactRevision({
187
+ revisionId,
188
+ parentRunId: input.parentRunId,
189
+ producerNodeId: input.producerNodeId,
190
+ declaration: input.declaration,
191
+ parentSha256: input.parentSha256,
192
+ parentBytes: input.parentBytes,
193
+ candidateBytes: input.candidateBytes,
194
+ mediaType: input.mediaType,
195
+ stagingPath,
196
+ uploadedAt: now.toISOString(),
197
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
198
+ ...(input.artifactSchemaResolver
199
+ ? { artifactSchemaResolver: input.artifactSchemaResolver }
200
+ : {}),
201
+ });
202
+ await writeFile(path.join(revisionDir, "candidate.bin"), input.candidateBytes, {
203
+ flag: "wx",
204
+ mode: 0o600,
205
+ });
206
+ if (described.diffPreview) {
207
+ await writeFile(path.join(revisionDir, "diff.txt"), described.diffPreview, {
208
+ flag: "wx",
209
+ mode: 0o600,
210
+ });
211
+ }
212
+ await writeSecureJson(metadataPath(input.appData, revisionId), described.revision);
213
+ return described.revision;
214
+ });
215
+ }
216
+ export async function readArtifactRevision(appData, revisionId) {
217
+ const raw = await readJsonIfExists(metadataPath(appData, revisionId));
218
+ if (raw === undefined)
219
+ return undefined;
220
+ return artifactRevisionV1Schema.parse(raw);
221
+ }
222
+ /**
223
+ * Explicit staging lease (M4c). A fresh lease held by another holder blocks
224
+ * conflicting mutations with LEASE_HELD; the same holder may renew (sliding
225
+ * TTL); a stale/expired lease is taken over. Recorded in metadata.json so a
226
+ * crashed holder stays visible until the TTL lapses.
227
+ */
228
+ export async function acquireArtifactRevisionLease(input) {
229
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
230
+ const current = await readArtifactRevision(input.appData, input.revisionId);
231
+ if (!current || current.state !== "validated") {
232
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
233
+ }
234
+ const now = input.now ?? new Date();
235
+ const ttlMs = input.ttlMs ?? DEFAULT_REVISION_LEASE_TTL_MS;
236
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
237
+ throw new Error("invalid lease TTL");
238
+ const existing = current.lease;
239
+ if (existing && Date.parse(existing.expiresAt) > now.getTime() && existing.holder !== input.holder) {
240
+ throw new Error("LEASE_HELD");
241
+ }
242
+ const leased = artifactRevisionV1Schema.parse({
243
+ ...current,
244
+ lease: {
245
+ holder: input.holder,
246
+ acquiredAt: now.toISOString(),
247
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
248
+ },
249
+ });
250
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), leased);
251
+ return leased;
252
+ });
253
+ }
254
+ export async function releaseArtifactRevisionLease(input) {
255
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
256
+ const current = await readArtifactRevision(input.appData, input.revisionId);
257
+ if (!current)
258
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
259
+ if (!current.lease)
260
+ return current;
261
+ if (current.lease.holder !== input.holder)
262
+ throw new Error("LEASE_HELD");
263
+ const { lease: _released, ...withoutLease } = current;
264
+ const released = artifactRevisionV1Schema.parse(withoutLease);
265
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), released);
266
+ return released;
267
+ });
268
+ }
269
+ export async function readArtifactRevisionCandidate(input) {
270
+ if (input.revision.state !== "validated")
271
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
272
+ if (Date.parse(input.revision.expiresAt) <= (input.now ?? new Date()).getTime()) {
273
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
274
+ }
275
+ const candidatePath = path.join(dir(input.appData, input.revision.revisionId), "candidate.bin");
276
+ // fd-based TOCTOU-safe read: open with O_NOFOLLOW and verify the opened fd
277
+ // (regular single-link) instead of lstat-then-readFile on the path. Map any
278
+ // path-safety rejection back to the stable ARTIFACT_REVISION_PATH_INVALID
279
+ // contract.
280
+ let bytes;
281
+ try {
282
+ bytes = await readFileBoundarySafe({
283
+ boundaryRoot: dir(input.appData, input.revision.revisionId),
284
+ targetPath: candidatePath,
285
+ label: `revision candidate ${input.revision.revisionId}`,
286
+ });
287
+ }
288
+ catch (error) {
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ if (/symlink|hardlink|single-link|regular|boundary|escap/i.test(message)) {
291
+ throw new Error("ARTIFACT_REVISION_PATH_INVALID");
292
+ }
293
+ throw error;
294
+ }
295
+ const actual = await import("node:crypto").then(({ createHash }) => createHash("sha256").update(bytes).digest("hex"));
296
+ if (actual !== input.revision.candidate.sha256 || bytes.byteLength !== input.revision.candidate.bytes) {
297
+ throw new Error("ARTIFACT_REVISION_DRIFT");
298
+ }
299
+ return bytes;
300
+ }
301
+ export function buildArtifactOverrideSet(revision) {
302
+ const overrides = [{
303
+ revisionId: revision.revisionId,
304
+ parentArtifact: {
305
+ runId: revision.parent.runId,
306
+ nodeId: revision.parent.nodeId,
307
+ path: revision.parent.artifactPath,
308
+ sha256: revision.parent.artifactSha256,
309
+ },
310
+ replacement: {
311
+ sha256: revision.candidate.sha256,
312
+ bytes: revision.candidate.bytes,
313
+ mediaType: revision.candidate.mediaType,
314
+ },
315
+ supersedes: revision.parent.artifactSha256,
316
+ }];
317
+ return { schemaVersion: 1, overrides, overrideSetHash: computeOverrideSetHash(overrides) };
318
+ }
319
+ export async function consumeArtifactRevision(input) {
320
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
321
+ const current = await readArtifactRevision(input.appData, input.revisionId);
322
+ if (!current || current.state !== "validated")
323
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
324
+ const now = input.now ?? new Date();
325
+ const existing = current.lease;
326
+ if (existing &&
327
+ Date.parse(existing.expiresAt) > now.getTime() &&
328
+ existing.holder !== (input.holder ?? "cli-mutation")) {
329
+ throw new Error("LEASE_HELD");
330
+ }
331
+ if (current.candidate.sha256 !== input.expectedSha256)
332
+ throw new Error("ARTIFACT_REVISION_DRIFT");
333
+ // Terminal states must never keep a live staging lock.
334
+ const consumed = artifactRevisionV1Schema.parse({
335
+ ...current,
336
+ state: "consumed",
337
+ lease: undefined,
338
+ });
339
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), consumed);
340
+ return consumed;
341
+ });
342
+ }
343
+ export async function discardArtifactRevision(input) {
344
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
345
+ const current = await readArtifactRevision(input.appData, input.revisionId);
346
+ if (!current)
347
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
348
+ const now = input.now ?? new Date();
349
+ if (current.state === "discarded")
350
+ return current;
351
+ if (current.state === "consumed")
352
+ throw new Error("ARTIFACT_REVISION_ALREADY_CONSUMED");
353
+ const existing = current.lease;
354
+ if (existing &&
355
+ Date.parse(existing.expiresAt) > now.getTime() &&
356
+ existing.holder !== (input.holder ?? "cli-mutation")) {
357
+ throw new Error("LEASE_HELD");
358
+ }
359
+ const discarded = artifactRevisionV1Schema.parse({
360
+ ...current,
361
+ state: "discarded",
362
+ lease: undefined,
363
+ });
364
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), discarded);
365
+ await Promise.all([
366
+ rm(path.join(dir(input.appData, input.revisionId), "candidate.bin"), { force: true }),
367
+ rm(path.join(dir(input.appData, input.revisionId), "diff.txt"), { force: true }),
368
+ ]);
369
+ return discarded;
370
+ });
371
+ }
372
+ export async function reconcileArtifactRevisions(input) {
373
+ ensureSecureDir(root(input.appData));
374
+ const now = input.now ?? new Date();
375
+ const removed = [];
376
+ for (const name of await readdir(root(input.appData))) {
377
+ if (!/^[a-f0-9]{32}$/.test(name))
378
+ continue;
379
+ const revision = await readArtifactRevision(input.appData, name).catch(() => undefined);
380
+ if (!revision || Date.parse(revision.expiresAt) <= now.getTime()) {
381
+ await rm(dir(input.appData, name), { recursive: true, force: true });
382
+ removed.push(name);
383
+ }
384
+ }
385
+ const uploadsDir = path.join(root(input.appData), "uploads");
386
+ const uploadsBefore = await lstat(uploadsDir).catch(() => undefined);
387
+ if (uploadsBefore?.isDirectory()) {
388
+ const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
389
+ for (const entry of await readdir(uploadsDir)) {
390
+ if (!/^[a-f0-9]{64}\.bin$/.test(entry))
391
+ continue;
392
+ const stat = await lstat(path.join(uploadsDir, entry)).catch(() => undefined);
393
+ if (!stat?.isFile() || stat.mtimeMs > cutoff)
394
+ continue;
395
+ await rm(path.join(uploadsDir, entry), { force: true });
396
+ removed.push(`uploads/${entry}`);
397
+ }
398
+ }
399
+ // Sweep orphaned staging-lock tombstones (takeover `.tomb` and release
400
+ // `.release` dirs left behind by a race) once they are older than the
401
+ // default TTL. Tombstones are never the live lock path.
402
+ const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
403
+ for (const entry of await readdir(root(input.appData))) {
404
+ if (!/^\.staging-lock\..+\.(?:tomb|release)$/.test(entry))
405
+ continue;
406
+ const stat = await lstat(path.join(root(input.appData), entry)).catch(() => undefined);
407
+ if (!stat?.isDirectory() || stat.mtimeMs > cutoff)
408
+ continue;
409
+ await rm(path.join(root(input.appData), entry), { recursive: true, force: true });
410
+ removed.push(`lock/${entry}`);
411
+ }
412
+ // Sweep orphaned per-revision mutation lock dirs (crashed holder) once they
413
+ // are older than the default TTL. The per-revision lock path is stable
414
+ // (`locks/<revisionId>.lock`); a fresh lock is never swept.
415
+ const locksDir = path.join(root(input.appData), "locks");
416
+ const locksStat = await lstat(locksDir).catch(() => undefined);
417
+ if (locksStat?.isDirectory()) {
418
+ for (const entry of await readdir(locksDir)) {
419
+ if (!/^[a-f0-9]{32}\.lock$/.test(entry))
420
+ continue;
421
+ const lockPath = path.join(locksDir, entry);
422
+ const stat = await lstat(lockPath).catch(() => undefined);
423
+ if (!stat?.isDirectory() || !await isDirLockStale(lockPath, DEFAULT_ARTIFACT_REVISION_TTL_MS, now.getTime()))
424
+ continue;
425
+ await rm(lockPath, { recursive: true, force: true });
426
+ removed.push(`lock/${entry}`);
427
+ }
428
+ }
429
+ return removed;
430
+ }