@tea-agent/loop-agent 0.38.1 → 0.38.3

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 (180) hide show
  1. package/CHANGELOG.md +126 -12
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/dag/generate-task-dag.js +9 -0
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init.js +2 -14
  8. package/dist/commands/task-advance.js +31 -0
  9. package/dist/executors/dag-pi-executor.js +99 -5
  10. package/dist/executors/pi-event-serializer.js +42 -1
  11. package/dist/executors/pi-executor.js +51 -0
  12. package/dist/executors/pi-extension-resolver.js +233 -0
  13. package/dist/executors/pi-sdk-executor.js +187 -59
  14. package/dist/executors/shell-executor.js +324 -16
  15. package/dist/executors/shell-write-guard.js +7 -0
  16. package/dist/governance/manifest-types.js +0 -2
  17. package/dist/shared/operator/capabilities.js +87 -22
  18. package/dist/task/config-types.js +22 -0
  19. package/dist/task/contract/constants.js +1 -0
  20. package/dist/task/contract/diff.js +26 -0
  21. package/dist/task/contract/project.js +5 -0
  22. package/dist/task/contract/schema.js +6 -1
  23. package/dist/task/source-prepare/build-draft.js +15 -0
  24. package/dist/worker/console/chat/deferred-turn.js +91 -0
  25. package/dist/worker/console/chat/pi-runtime.js +10 -5
  26. package/dist/worker/console/chat/routes.js +458 -47
  27. package/dist/worker/console/chat/semantic-activity.js +13 -13
  28. package/dist/worker/console/chat/session-store.js +146 -2
  29. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  30. package/dist/worker/console/operator-actions.js +5 -4
  31. package/dist/worker/console/pi-readiness.js +5 -4
  32. package/dist/worker/console/recovery-cta.js +4 -4
  33. package/dist/worker/console/recovery-error-copy.js +4 -4
  34. package/dist/worker/console/server.js +6 -1
  35. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  36. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-Bj2M8iO5.js} +1 -1
  37. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  38. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  39. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  40. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  41. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  51. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  52. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  53. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  54. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  55. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  56. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  59. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  62. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  63. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  64. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  65. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  66. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  67. package/dist/worker/console/static/assets/index-DTZOKgAn.js +404 -0
  68. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  69. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  70. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  71. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  72. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  73. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-DdnapKIH.js} +1 -1
  74. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-BVjAT9b8.js} +5 -5
  75. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  76. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  77. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  78. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  79. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  80. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  81. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  82. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  83. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  84. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  86. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  87. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  88. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  89. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  90. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  91. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  92. package/dist/worker/console/static/index.html +2 -2
  93. package/dist/worker/console/static-src/app/console-types.js +0 -12
  94. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  95. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  97. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  98. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  99. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  100. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  101. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  102. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  103. package/dist/worker/observe/node-input.js +72 -3
  104. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  105. package/dist/worker/observe/static/state.js +2 -2
  106. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  107. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  108. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +809 -53
  109. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  110. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  111. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  112. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  113. package/dist/workflows/dag/backend-test-layout.js +133 -0
  114. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  115. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  116. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  117. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  118. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  119. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  120. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  121. package/dist/workflows/dag/contract-output-registry.js +15 -0
  122. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  123. package/dist/workflows/dag/convergence/controller.js +141 -0
  124. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  125. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  126. package/dist/workflows/dag/failure-category.js +4 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +355 -26
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +96 -84
  129. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  130. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  131. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  132. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  133. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  134. package/dist/workflows/dag/init-hybrid.js +720 -177
  135. package/dist/workflows/dag/node-execution.js +164 -26
  136. package/dist/workflows/dag/prompt.js +60 -1
  137. package/dist/workflows/dag/rerun-feedback.js +212 -0
  138. package/dist/workflows/dag/rerun-plan.js +112 -27
  139. package/dist/workflows/dag/rerun-run.js +3 -0
  140. package/dist/workflows/dag/rerun-task.js +61 -2
  141. package/dist/workflows/dag/retry-policy.js +18 -0
  142. package/dist/workflows/dag/structured-output-repair.js +712 -0
  143. package/dist/workflows/dag/types.js +121 -0
  144. package/dist/workflows/dag/validate.js +41 -1
  145. package/docs/operations/local-development-environment.md +1 -5
  146. package/docs/skills/vetted-skill-registry.md +14 -0
  147. package/docs/templates/README.md +1 -1
  148. package/docs/templates/agent-dag.schema.json +44 -0
  149. package/docs/templates/backend-test-dag.json +60 -35
  150. package/docs/templates/frontend-test-dag.json +8 -10
  151. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  152. package/docs/templates/harness.schema.json +0 -8
  153. package/harness.json +1 -8
  154. package/package.json +8 -4
  155. package/skills/analyze-product-requirements/SKILL.md +8 -5
  156. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  157. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  158. package/skills/analyze-product-requirements/references/example.md +50 -0
  159. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  160. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  161. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  162. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  163. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  164. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  165. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  166. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  167. package/skills/codebase-scout/SKILL.md +1 -1
  168. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  169. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  170. package/skills/improve-codebase-architecture/deepening.md +37 -0
  171. package/skills/improve-codebase-architecture/html-report.md +123 -0
  172. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  173. package/skills/improve-codebase-architecture/language.md +53 -0
  174. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  175. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  176. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  177. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  178. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  179. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  180. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -1,8 +1,8 @@
1
1
  import { useCallback, useEffect } from "react";
2
2
  import { formatOperatorUserError } from "../../operator-user-error.js";
3
3
  import { confirmationToken, requestId, sleep } from "./format.js";
4
- import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
5
- import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, } from "./turn-stream-controller.js";
4
+ import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, decideSubmissionRoute, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcileDeferredSubmissionFromState, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
5
+ import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, turnPhaseFromOwnership, } from "./turn-stream-controller.js";
6
6
  /** Project ChatRefs into the executable ownership bag used by the controller. */
7
7
  function ownershipBagFromRefs(refs) {
8
8
  return {
@@ -57,7 +57,7 @@ function clearEmptyAssistant(setMessages, assistantId) {
57
57
  * detached Turn resource only; it never opens a second inline SSE consumer. */
58
58
  export function useChatStream(params) {
59
59
  const { origin, refs, session, thread, composer, setError, setAutoScroll, setNotice, recoveryVersion = 0, watchSessionTitle, } = params;
60
- const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
60
+ const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, setTurnPhase, setDeferredTurn, } = thread;
61
61
  const { setInput, setPendingImages } = composer;
62
62
  const showNotice = useCallback((message) => {
63
63
  if (!setNotice)
@@ -78,9 +78,10 @@ export function useChatStream(params) {
78
78
  refs.assistantIdRef.current = null;
79
79
  refs.stoppingTurnIdRef.current = null;
80
80
  setStreaming(false);
81
+ setTurnPhase("idle");
81
82
  setStreamingAssistantId(null);
82
83
  clearEmptyAssistant(setMessages, assistantId);
83
- }, [refs, setStreaming, setStreamingAssistantId, setMessages]);
84
+ }, [refs, setStreaming, setTurnPhase, setStreamingAssistantId, setMessages]);
84
85
  /** Follow one session's event stream until it is explicitly aborted. The
85
86
  * server's `stream_ready` marker separates replay from live events; this
86
87
  * means replay cannot render duplicate messages while live text remains
@@ -242,13 +243,12 @@ export function useChatStream(params) {
242
243
  /** Issue real POST /abort for a known turnId (Stop never disposes Session). */
243
244
  const postAbortForTurn = useCallback(async (sessionId, generation, turnId) => {
244
245
  refs.stoppingTurnIdRef.current = turnId;
245
- // Optimistic stop (pi-web parity): the abort signal kills the SDK
246
- // process tree promptly, so flip the UI idle NOW instead of holding
247
- // the busy spinner through the whole POST /abort round-trip. If the
248
- // server later reports the turn still running (TURN_ABORT_PENDING
249
- // or owner-lost reconcile), applyAbortDecision restores busy state.
246
+ // Optimistic visual stop: remove the running spinner immediately, but
247
+ // retain ownership as `stopping` until the server proves a terminal state.
248
+ // This keeps the composer responsive without falsely advertising idle.
250
249
  refs.streamingRef.current = false;
251
250
  setStreaming(false);
251
+ setTurnPhase("stopping");
252
252
  setStreamingAssistantId(null);
253
253
  try {
254
254
  const res = await fetch(operatorChatAbortUrl(origin, sessionId, turnId), {
@@ -283,6 +283,7 @@ export function useChatStream(params) {
283
283
  markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
284
284
  });
285
285
  applyOwnershipBagToRefs(refs, bag);
286
+ setTurnPhase(turnPhaseFromOwnership(bag));
286
287
  return;
287
288
  }
288
289
  applyAbortDecision(bag, decision, noticeSink(), {
@@ -290,6 +291,7 @@ export function useChatStream(params) {
290
291
  markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
291
292
  });
292
293
  applyOwnershipBagToRefs(refs, bag);
294
+ setTurnPhase(turnPhaseFromOwnership(bag));
293
295
  // Sync the post-decision busy state back to React: optimistic stop
294
296
  // flipped it idle up front; pending/keep-busy outcomes restore it.
295
297
  if (bag.streaming)
@@ -310,11 +312,13 @@ export function useChatStream(params) {
310
312
  // so the turn is not silently dropped; /state reconcile converges.
311
313
  refs.streamingRef.current = true;
312
314
  setStreaming(true);
315
+ setTurnPhase("stopping");
313
316
  }
314
317
  }, [
315
318
  origin,
316
319
  refs,
317
320
  setStreaming,
321
+ setTurnPhase,
318
322
  markIdleIfCurrent,
319
323
  fetchSessionState,
320
324
  noticeSink,
@@ -364,10 +368,12 @@ export function useChatStream(params) {
364
368
  },
365
369
  });
366
370
  applyOwnershipBagToRefs(refs, bag);
371
+ setTurnPhase(turnPhaseFromOwnership(bag));
367
372
  }, [
368
373
  refs,
369
374
  noticeSink,
370
375
  setStreaming,
376
+ setTurnPhase,
371
377
  setStreamingAssistantId,
372
378
  setPendingImages,
373
379
  watchSessionTitle,
@@ -376,10 +382,190 @@ export function useChatStream(params) {
376
382
  setError,
377
383
  postAbortForTurn,
378
384
  ]);
385
+ const queueDeferredTurn = useCallback(async (input) => {
386
+ const activeSession = refs.sessionRef.current;
387
+ if (!activeSession || (!input.text.trim() && input.images.length === 0))
388
+ return { action: "handled" };
389
+ const existing = thread.deferredTurn;
390
+ showNotice("正在排队…");
391
+ const acceptDeferred = (deferredTurn) => {
392
+ setDeferredTurn(deferredTurn);
393
+ setInput("");
394
+ setPendingImages([]);
395
+ setError(null);
396
+ showNotice(existing ? "已更新停止后发送的消息" : "已排队,将在上一轮停止后自动发送");
397
+ };
398
+ const acceptDispatched = () => {
399
+ setDeferredTurn(null);
400
+ setInput("");
401
+ setPendingImages([]);
402
+ setError(null);
403
+ showNotice("消息已进入发送");
404
+ };
405
+ const putOnce = async () => {
406
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
407
+ method: "PUT",
408
+ headers: {
409
+ "Content-Type": "application/json",
410
+ "x-loop-console-confirmation": confirmationToken(),
411
+ },
412
+ credentials: "include",
413
+ body: JSON.stringify({
414
+ clientRequestId: input.clientRequestId,
415
+ blockedByTurnId: input.blockedByTurnId,
416
+ text: input.text,
417
+ images: input.images,
418
+ ...(existing ? { expectedRevision: existing.revision } : {}),
419
+ }),
420
+ });
421
+ const body = (await response.json().catch(() => ({})));
422
+ return { response, body };
423
+ };
424
+ try {
425
+ for (let attempt = 0; attempt < 2; attempt += 1) {
426
+ const { response, body } = await putOnce();
427
+ if (response.ok && body.accepted && body.deferredTurn) {
428
+ acceptDeferred(body.deferredTurn);
429
+ return { action: "handled" };
430
+ }
431
+ if (body.deferredTurn)
432
+ setDeferredTurn(body.deferredTurn);
433
+ if (body.error?.code !== "TURN_NOT_STOPPING") {
434
+ setError(body.error?.message ??
435
+ `待发送消息未被服务端接受(HTTP ${response.status})`);
436
+ return { action: "handled" };
437
+ }
438
+ const canonical = await fetchSessionState(activeSession.sessionId);
439
+ const decision = reconcileDeferredSubmissionFromState({
440
+ clientRequestId: input.clientRequestId,
441
+ blockedByTurnId: input.blockedByTurnId,
442
+ state: canonical ?? undefined,
443
+ });
444
+ if (decision.action === "deferred") {
445
+ acceptDeferred(decision.deferredTurn);
446
+ return { action: "handled" };
447
+ }
448
+ if (decision.action === "accepted") {
449
+ acceptDispatched();
450
+ return { action: "handled" };
451
+ }
452
+ if (decision.action === "retry-deferred" && attempt === 0)
453
+ continue;
454
+ if (decision.action === "send-now" && !existing) {
455
+ showNotice("上一轮已停止,正在发送当前消息…");
456
+ return { action: "send-now" };
457
+ }
458
+ setError(body.error?.message ?? "停止状态已变化,当前消息仍保留在输入框");
459
+ return { action: "handled" };
460
+ }
461
+ return { action: "handled" };
462
+ }
463
+ catch (error) {
464
+ const canonical = await fetchSessionState(activeSession.sessionId);
465
+ const decision = reconcileDeferredSubmissionFromState({
466
+ clientRequestId: input.clientRequestId,
467
+ blockedByTurnId: input.blockedByTurnId,
468
+ state: canonical ?? undefined,
469
+ });
470
+ if (decision.action === "deferred") {
471
+ acceptDeferred(decision.deferredTurn);
472
+ return { action: "handled" };
473
+ }
474
+ if (decision.action === "accepted") {
475
+ acceptDispatched();
476
+ return { action: "handled" };
477
+ }
478
+ if (decision.action === "send-now" && !existing) {
479
+ showNotice("上一轮已停止,正在发送当前消息…");
480
+ return { action: "send-now" };
481
+ }
482
+ setError(error instanceof Error
483
+ ? `无法确认消息是否已排队:${error.message}`
484
+ : "无法确认消息是否已排队,请刷新后查看服务端状态");
485
+ return { action: "handled" };
486
+ }
487
+ }, [
488
+ origin,
489
+ refs,
490
+ thread.deferredTurn,
491
+ setDeferredTurn,
492
+ setInput,
493
+ setPendingImages,
494
+ setError,
495
+ showNotice,
496
+ fetchSessionState,
497
+ ]);
498
+ const cancelDeferredTurn = useCallback(async () => {
499
+ const activeSession = refs.sessionRef.current;
500
+ const deferred = thread.deferredTurn;
501
+ if (!activeSession || !deferred)
502
+ return;
503
+ try {
504
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
505
+ method: "DELETE",
506
+ headers: {
507
+ "Content-Type": "application/json",
508
+ "x-loop-console-confirmation": confirmationToken(),
509
+ },
510
+ credentials: "include",
511
+ body: JSON.stringify({
512
+ clientRequestId: deferred.clientRequestId,
513
+ expectedRevision: deferred.revision,
514
+ }),
515
+ });
516
+ const body = (await response.json().catch(() => ({})));
517
+ if (!response.ok || !body.cancelled) {
518
+ setError(body.error?.message ?? "待发送消息撤回失败");
519
+ return;
520
+ }
521
+ setDeferredTurn(null);
522
+ setInput(body.deferredTurn?.text ?? "");
523
+ setPendingImages(body.deferredTurn?.images ?? []);
524
+ setError(null);
525
+ showNotice("待发送消息已移回输入框");
526
+ }
527
+ catch (error) {
528
+ setError(error instanceof Error ? error.message : String(error));
529
+ }
530
+ }, [
531
+ origin,
532
+ refs,
533
+ thread.deferredTurn,
534
+ setDeferredTurn,
535
+ setInput,
536
+ setPendingImages,
537
+ setError,
538
+ showNotice,
539
+ ]);
379
540
  const sendPrompt = useCallback(async (text, images) => {
541
+ const initialSession = refs.sessionRef.current;
542
+ if (!initialSession || (!text.trim() && images.length === 0))
543
+ return;
544
+ const ownership = ownershipBagFromRefs(refs);
545
+ const route = decideSubmissionRoute({
546
+ turnPhase: turnPhaseFromOwnership(ownership),
547
+ blockedByTurnId: ownership.stoppingTurnId ?? ownership.activeTurnId,
548
+ });
549
+ const clientRequestId = requestId("chat-submit");
550
+ if (route.action === "hold") {
551
+ if (turnPhaseFromOwnership(ownership) === "stopping")
552
+ showNotice("正在确认上一轮停止状态,当前消息已保留");
553
+ return;
554
+ }
555
+ if (route.action === "queue-deferred") {
556
+ const deferred = await queueDeferredTurn({
557
+ text,
558
+ images,
559
+ clientRequestId,
560
+ blockedByTurnId: route.blockedByTurnId,
561
+ });
562
+ if (deferred.action !== "send-now")
563
+ return;
564
+ }
380
565
  const activeSession = refs.sessionRef.current;
381
566
  const generation = refs.sessionGenerationRef.current;
382
567
  if (!activeSession ||
568
+ activeSession.sessionId !== initialSession.sessionId ||
383
569
  (!text.trim() && images.length === 0) ||
384
570
  refs.streamingRef.current ||
385
571
  refs.submitInFlightRef.current ||
@@ -389,7 +575,6 @@ export function useChatStream(params) {
389
575
  // AC-001: acquire synchronous submit ownership before the first await.
390
576
  refs.submitInFlightRef.current = true;
391
577
  const submitGeneration = ++refs.submitGenerationRef.current;
392
- const clientRequestId = requestId("chat-submit");
393
578
  const userMessageId = requestId("u");
394
579
  const assistantId = requestId("a");
395
580
  const draftText = text.trim();
@@ -437,6 +622,7 @@ export function useChatStream(params) {
437
622
  setAutoScroll(true);
438
623
  setError(null);
439
624
  refs.streamingRef.current = true;
625
+ setTurnPhase("running");
440
626
  refs.assistantIdRef.current = assistantId;
441
627
  setStreaming(true);
442
628
  setStreamingAssistantId(assistantId);
@@ -532,7 +718,9 @@ export function useChatStream(params) {
532
718
  setAutoScroll,
533
719
  setError,
534
720
  setStreaming,
721
+ setTurnPhase,
535
722
  setStreamingAssistantId,
723
+ queueDeferredTurn,
536
724
  ]);
537
725
  const handleStop = useCallback(async () => {
538
726
  // AC-R5-001: durable stopRequested is set synchronously before any await so
@@ -548,6 +736,7 @@ export function useChatStream(params) {
548
736
  notice: noticeSink(),
549
737
  });
550
738
  applyOwnershipBagToRefs(refs, bag);
739
+ setTurnPhase("stopping");
551
740
  // Immediate abort already scheduled for a known turnId — coalesce via
552
741
  // abortPostedTurnIds; stopInFlight only gates concurrent in-flight POSTs.
553
742
  if (stopResult.abortedTurnId) {
@@ -656,6 +845,9 @@ export function useChatStream(params) {
656
845
  },
657
846
  });
658
847
  applyOwnershipBagToRefs(refs, bag);
848
+ setTurnPhase(turnPhaseFromOwnership(bag));
849
+ if (state && "deferredTurn" in state)
850
+ setDeferredTurn(state.deferredTurn ?? null);
659
851
  if (result === "bound") {
660
852
  // US-BUSY-ACTIVE-BOUND: keep streaming/busy with same id.
661
853
  setStreaming(true);
@@ -755,6 +947,7 @@ export function useChatStream(params) {
755
947
  refs,
756
948
  session,
757
949
  streaming,
950
+ setTurnPhase,
758
951
  markIdleIfCurrent,
759
952
  releaseSubmitOwnership,
760
953
  postAbortForTurn,
@@ -776,5 +969,5 @@ export function useChatStream(params) {
776
969
  }
777
970
  };
778
971
  }, [session, recoveryVersion, refs, startEventsStream]);
779
- return { sendPrompt, handleStop, openEventsStream };
972
+ return { sendPrompt, handleStop, cancelDeferredTurn, openEventsStream };
780
973
  }
@@ -31,6 +31,8 @@ export function useChatThread(params) {
31
31
  const [contextLoading, setContextLoading] = useState(false);
32
32
  const [usage, setUsage] = useState(EMPTY_USAGE);
33
33
  const [streaming, setStreaming] = useState(false);
34
+ const [turnPhase, setTurnPhase] = useState("idle");
35
+ const [deferredTurn, setDeferredTurn] = useState(null);
34
36
  /** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
35
37
  const [streamingAssistantId, setStreamingAssistantId] = useState(null);
36
38
  const [showSystemInMessages] = useState(false);
@@ -44,6 +46,8 @@ export function useChatThread(params) {
44
46
  setHumanGateCards,
45
47
  setArtifactCards,
46
48
  setStreaming,
49
+ setTurnPhase,
50
+ setDeferredTurn,
47
51
  setStreamingAssistantId,
48
52
  setUnread,
49
53
  setOperationCards,
@@ -190,6 +194,8 @@ export function useChatThread(params) {
190
194
  setTaskContext(null);
191
195
  setUsage(EMPTY_USAGE);
192
196
  setStreaming(false);
197
+ setTurnPhase("idle");
198
+ setDeferredTurn(null);
193
199
  setStreamingAssistantId(null);
194
200
  setUnread(0);
195
201
  setHistoryLimit(50);
@@ -207,6 +213,8 @@ export function useChatThread(params) {
207
213
  setUsage(EMPTY_USAGE);
208
214
  refs.usageResponseKeysRef.current = new Set();
209
215
  setStreaming(next.streaming);
216
+ setTurnPhase(next.turnPhase ?? (next.streaming ? "running" : "idle"));
217
+ setDeferredTurn(next.deferredTurn ?? null);
210
218
  setStreamingAssistantId(null);
211
219
  setUnread(0);
212
220
  setHistoryLimit(50);
@@ -228,6 +236,8 @@ export function useChatThread(params) {
228
236
  const activeTurnId = body.session.activeTurnId ?? null;
229
237
  refs.activeTurnIdRef.current = activeTurnId;
230
238
  refs.streamingRef.current = Boolean(activeTurnId);
239
+ setTurnPhase(body.session.turnPhase ?? (activeTurnId ? "running" : "idle"));
240
+ setDeferredTurn(body.session.deferredTurn ?? null);
231
241
  // Resume the events stream from the snapshot tail so the next
232
242
  // reconnect never immediately re-expires (AC-003). The refs below
233
243
  // are reset so any follow-up tail replay re-applies cleanly.
@@ -246,7 +256,15 @@ export function useChatThread(params) {
246
256
  // trigger another reconcile and retry (best-effort recovery).
247
257
  return false;
248
258
  }
249
- }, [origin, refs, setMessages, setToolCalls, setStreamingAssistantId]);
259
+ }, [
260
+ origin,
261
+ refs,
262
+ setMessages,
263
+ setToolCalls,
264
+ setStreamingAssistantId,
265
+ setTurnPhase,
266
+ setDeferredTurn,
267
+ ]);
250
268
  return {
251
269
  messages,
252
270
  setMessages,
@@ -265,6 +283,10 @@ export function useChatThread(params) {
265
283
  usage,
266
284
  streaming,
267
285
  setStreaming,
286
+ turnPhase,
287
+ setTurnPhase,
288
+ deferredTurn,
289
+ setDeferredTurn,
268
290
  streamingAssistantId,
269
291
  setStreamingAssistantId,
270
292
  visibleMessages,
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
4
4
  import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
5
- import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
5
+ import { resolveDagNodePromptRedacted, resolveDagRunArtifact, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
6
  export const NODE_INPUT_SCHEMA_VERSION = 1;
7
7
  export const NODE_INPUT_PREVIEW_MAX_BYTES = 64 * 1024;
8
8
  const SHELL_GATE_KEYS = [
@@ -33,6 +33,7 @@ function unavailableBody(dagRunId, nodeId, reason, warnings = []) {
33
33
  executorInput: null,
34
34
  boundaries: null,
35
35
  promptFingerprint: null,
36
+ piExtensionRunFacts: null,
36
37
  warnings,
37
38
  };
38
39
  }
@@ -137,7 +138,7 @@ function resolveModelHint(spec, task) {
137
138
  }
138
139
  return null;
139
140
  }
140
- function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warnings) {
141
+ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, piExtensionRunFacts, warnings) {
141
142
  const defaults = spec.defaults && typeof spec.defaults === "object"
142
143
  ? spec.defaults
143
144
  : undefined;
@@ -268,6 +269,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
268
269
  kind,
269
270
  skills,
270
271
  modelHint: resolveModelHint(spec, task),
272
+ piExtensions: redactStringList(task.piExtensions),
271
273
  shell,
272
274
  static: staticBlock,
273
275
  },
@@ -284,9 +286,72 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
284
286
  : null,
285
287
  },
286
288
  promptFingerprint: fingerprint,
289
+ piExtensionRunFacts,
287
290
  warnings,
288
291
  };
289
292
  }
293
+ /**
294
+ * Load run-time Pi extension facts from <node>/pi-extensions.json (plan
295
+ * 2026-08-22 R2). Returns null when the node declares no piExtensions
296
+ * (no expected artifact). Never throws: unreadable/corrupt artifacts degrade
297
+ * to parseError stubs so the node-input endpoint stays 200.
298
+ */
299
+ async function loadPiExtensionRunFacts(repoRoot, dagRunId, nodeId, declared, warnings) {
300
+ if (declared.length === 0)
301
+ return null;
302
+ const artifact = path.posix.join(nodeId, "pi-extensions.json");
303
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, [
304
+ nodeId,
305
+ "pi-extensions.json",
306
+ ]);
307
+ if (!resolved.ok) {
308
+ if (resolved.reason === "ambiguous") {
309
+ warnings.push("Ambiguous pi-extensions.json lifecycle copies.");
310
+ }
311
+ return {
312
+ artifact,
313
+ exists: false,
314
+ requested: [...declared],
315
+ resolved: [],
316
+ missing: [],
317
+ };
318
+ }
319
+ const empty = {
320
+ artifact,
321
+ exists: false,
322
+ requested: [...declared],
323
+ resolved: [],
324
+ missing: [],
325
+ };
326
+ try {
327
+ const raw = JSON.parse(await readFile(resolved.result.absolutePath, "utf-8"));
328
+ const resolvedList = Array.isArray(raw.resolved) ? raw.resolved : [];
329
+ const missingList = Array.isArray(raw.missing) ? raw.missing : [];
330
+ const projected = resolvedList
331
+ .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
332
+ .map((entry) => ({
333
+ id: redactSecrets(String(entry.id ?? "")),
334
+ packageName: redactSecrets(String(entry.packageName ?? "")),
335
+ version: redactSecrets(String(entry.version ?? "")),
336
+ }));
337
+ const missingProjected = missingList
338
+ .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
339
+ .map((entry) => ({
340
+ id: redactSecrets(String(entry.id ?? "")),
341
+ reason: redactSecrets(String(entry.reason ?? "")),
342
+ }));
343
+ return {
344
+ artifact,
345
+ exists: true,
346
+ requested: redactStringList(raw.requested ?? declared),
347
+ resolved: projected,
348
+ missing: missingProjected,
349
+ };
350
+ }
351
+ catch {
352
+ return { ...empty, parseError: "read-failed" };
353
+ }
354
+ }
290
355
  async function loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings) {
291
356
  const resolved = resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId);
292
357
  if (!resolved.ok) {
@@ -415,8 +480,12 @@ export async function buildDagNodeInput(repoRoot, dagRunId, nodeId) {
415
480
  }
416
481
  const warnings = [];
417
482
  const fingerprint = await loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings);
483
+ const declaredPiExtensions = Array.isArray(task.piExtensions)
484
+ ? task.piExtensions.filter((item) => typeof item === "string")
485
+ : [];
486
+ const piExtensionRunFacts = await loadPiExtensionRunFacts(repoRoot, dagRunId, nodeId, declaredPiExtensions, warnings);
418
487
  // Reject unknown nested leakage by projecting only through allowlist helpers.
419
- const body = projectTask(dagRunId, nodeId, runResolved.result.lifecycle, spec, task, fingerprint, warnings);
488
+ const body = projectTask(dagRunId, nodeId, runResolved.result.lifecycle, spec, task, fingerprint, piExtensionRunFacts, warnings);
420
489
  // Defense-in-depth: ensure raw task never sneaks in.
421
490
  if ("task" in body) {
422
491
  return {
@@ -14,6 +14,10 @@ const ATTEMPT_FAILURE_LABELS = {
14
14
  timeout: "执行超时",
15
15
  "nonzero-exit": "命令执行失败",
16
16
  "invalid-output": "输出格式无效",
17
+ "structured-output-truncated": "结构化输出被截断",
18
+ "output-too-large": "输出超出体积上限",
19
+ "structured-repair-exhausted": "结构化修复耗尽",
20
+ "governance-blocked": "治理规则阻断",
17
21
  "empty-output": "输出为空",
18
22
  auth: "认证失败",
19
23
  quota: "额度或限流",
@@ -76,8 +76,8 @@ export const uiState = {
76
76
  dagNodeExecutionOutputLoading: false,
77
77
  dagNodeExecutionOutputError: null,
78
78
  dagNodeExecutionOutputKey: null,
79
- /** When true,「执行过程」展开 message_start/end 原始协议事件。 */
80
- sessionTimelineShowProtocol: false,
79
+ /** When true,「执行过程」展开 message_start/end 原始协议事件。默认展开,用户可点选隐藏。 */
80
+ sessionTimelineShowProtocol: true,
81
81
  /** Open <details> keys restored across timeline re-renders. */
82
82
  sessionTimelineOpenDetails: null,
83
83
  lastSnapshot: null,
@@ -725,6 +725,11 @@ function renderDagNodeInputDetail(content, data) {
725
725
  }
726
726
  section.appendChild(dl);
727
727
  appendCodeList(section, "skills", data.executorInput.skills);
728
+ appendCodeList(
729
+ section,
730
+ "piExtensions(声明)",
731
+ data.executorInput.piExtensions,
732
+ );
728
733
  if (data.executorInput.shell) {
729
734
  const shell = data.executorInput.shell;
730
735
  const shellDl = el("dl", "node-input-summary");
@@ -799,6 +804,52 @@ function renderDagNodeInputDetail(content, data) {
799
804
  wrap.appendChild(section);
800
805
  }
801
806
 
807
+ if (data.piExtensionRunFacts) {
808
+ const facts = data.piExtensionRunFacts;
809
+ const section = el("section", "node-input-section");
810
+ section.appendChild(el("h4", null, "Pi 扩展运行事实"));
811
+ if (!facts.exists) {
812
+ section.appendChild(
813
+ el(
814
+ "p",
815
+ "muted",
816
+ facts.parseError
817
+ ? `产物 ${facts.artifact} 读取失败(${facts.parseError})。`
818
+ : `产物 ${facts.artifact} 尚未生成(节点未执行或缺失)。`,
819
+ ),
820
+ );
821
+ } else {
822
+ const dl = el("dl", "node-input-summary");
823
+ appendMetaRow(
824
+ dl,
825
+ "resolved",
826
+ facts.resolved
827
+ .map((entry) => `${entry.id} → ${entry.packageName}@${entry.version}`)
828
+ .join(", ") || "(none)",
829
+ null,
830
+ );
831
+ section.appendChild(dl);
832
+ appendCodeList(
833
+ section,
834
+ "requested",
835
+ facts.requested,
836
+ );
837
+ for (const missingEntry of facts.missing ?? []) {
838
+ section.appendChild(
839
+ el(
840
+ "p",
841
+ "node-input-warning",
842
+ `降级:${missingEntry.id} 未加载(${missingEntry.reason || "unknown"})`,
843
+ ),
844
+ );
845
+ }
846
+ if ((facts.missing ?? []).length === 0) {
847
+ section.appendChild(el("p", "muted", "全部声明扩展均已解析加载。"));
848
+ }
849
+ }
850
+ wrap.appendChild(section);
851
+ }
852
+
802
853
  if (data.promptFingerprint?.exists) {
803
854
  const section = el("section", "node-input-section");
804
855
  section.appendChild(el("h4", null, "Assembled prompt 指纹"));
@@ -439,12 +439,25 @@ function ensureOpenDetailsSet() {
439
439
  return uiState.sessionTimelineOpenDetails;
440
440
  }
441
441
 
442
- function bindDetailsToggle(details, detailKey) {
442
+ function closedDetailKey(detailKey) {
443
+ return `${detailKey}::user-closed`;
444
+ }
445
+
446
+ function bindDetailsToggle(details, detailKey, defaultOpen = false) {
443
447
  const openSet = ensureOpenDetailsSet();
444
- if (openSet.has(detailKey)) details.open = true;
448
+ if (defaultOpen) {
449
+ if (!openSet.has(closedDetailKey(detailKey))) details.open = true;
450
+ } else if (openSet.has(detailKey)) {
451
+ details.open = true;
452
+ }
445
453
  details.addEventListener("toggle", () => {
446
- if (details.open) openSet.add(detailKey);
447
- else openSet.delete(detailKey);
454
+ if (details.open) {
455
+ openSet.add(detailKey);
456
+ openSet.delete(closedDetailKey(detailKey));
457
+ } else {
458
+ openSet.delete(detailKey);
459
+ openSet.add(closedDetailKey(detailKey));
460
+ }
448
461
  });
449
462
  }
450
463
 
@@ -599,7 +612,7 @@ function renderProtocolCard(item) {
599
612
  const details = document.createElement("details");
600
613
  details.className = "process-timeline-details";
601
614
  details.dataset.detailKey = item.detailKey;
602
- bindDetailsToggle(details, item.detailKey);
615
+ bindDetailsToggle(details, item.detailKey, true);
603
616
  const summary = document.createElement("summary");
604
617
  summary.className = "process-timeline-summary";
605
618
  summary.textContent = `消息收发 · ${item.protocolCount} 条协议事件`;