@tea-agent/loop-agent 0.39.0-next.25 → 0.39.0-next.27

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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/bin/loop-agent.js +7 -3
  3. package/dist/build-stamp.json +2 -2
  4. package/dist/executors/dag-pi-executor.js +58 -1
  5. package/dist/executors/pi-executor.js +51 -0
  6. package/dist/executors/pi-extension-resolver.js +233 -0
  7. package/dist/executors/pi-sdk-executor.js +182 -54
  8. package/dist/executors/shell-executor.js +54 -0
  9. package/dist/executors/shell-write-guard.js +7 -0
  10. package/dist/worker/observe/node-input.js +72 -3
  11. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  12. package/dist/worker/observe/static/state.js +2 -2
  13. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  14. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  15. package/dist/workflows/dag/contract-output-registry.js +15 -0
  16. package/dist/workflows/dag/failure-category.js +4 -0
  17. package/dist/workflows/dag/frontend-implementation-contract.js +140 -39
  18. package/dist/workflows/dag/frontend-prewrite-gate.js +87 -135
  19. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  20. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  21. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  22. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  23. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  24. package/dist/workflows/dag/init-hybrid.js +52 -94
  25. package/dist/workflows/dag/node-execution.js +119 -8
  26. package/dist/workflows/dag/prompt.js +15 -1
  27. package/dist/workflows/dag/rerun-plan.js +22 -3
  28. package/dist/workflows/dag/structured-output-repair.js +712 -0
  29. package/dist/workflows/dag/types.js +23 -0
  30. package/dist/workflows/dag/validate.js +41 -1
  31. package/docs/templates/agent-dag.schema.json +44 -0
  32. package/docs/templates/frontend-test-dag.json +8 -10
  33. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  34. package/package.json +1 -1
  35. package/skills/codebase-scout/SKILL.md +1 -1
@@ -1,8 +1,16 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from "./pi-executor.js";
3
+ import { computeExtensionToolAllowlistExtras } from "./pi-extension-resolver.js";
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";
7
+ /** Same-session recovery after a transport stall. Exactly one continue per SDK attempt. */
8
+ export const PI_SDK_STALL_CONTINUE_MAX = 1;
9
+ /**
10
+ * Fixed follow-up sent after aborting a stalled prompt.
11
+ * Kept short so the model can resume without repeating the original task payload.
12
+ */
13
+ export const PI_SDK_STALL_CONTINUE_PROMPT = "继续。上一轮因长时间无 provider 活动已被中止;请从中断处接着完成,不要重复已完成的步骤。若此前没有有效输出,请重新执行原任务。";
6
14
  let sdkSessionFactoryOverride;
7
15
  let sdkImportOverrideForTests;
8
16
  let sdkModuleOverrideForTests;
@@ -222,6 +230,9 @@ async function createSdkSession(sdk, input, shared) {
222
230
  noContextFiles: true,
223
231
  noSkills: true,
224
232
  noExtensions: true,
233
+ ...(input.additionalExtensionPaths && input.additionalExtensionPaths.length > 0
234
+ ? { additionalExtensionPaths: input.additionalExtensionPaths }
235
+ : {}),
225
236
  ...(settingsManager ? { settingsManager } : {}),
226
237
  appendSystemPromptOverride: (base) => [
227
238
  ...base,
@@ -229,6 +240,19 @@ async function createSdkSession(sdk, input, shared) {
229
240
  ],
230
241
  });
231
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];
232
256
  if (input.requireWriterCustomTools) {
233
257
  if (!Array.isArray(input.customTools) || input.customTools.length === 0) {
234
258
  throw new Error("pi writer tool policy missing customTools; refusing to create uncontrolled writer session");
@@ -238,7 +262,7 @@ async function createSdkSession(sdk, input, shared) {
238
262
  cwd: input.cwd,
239
263
  sessionManager: SessionManager.inMemory(input.cwd),
240
264
  resourceLoader: loader,
241
- tools: input.toolNames,
265
+ tools: sessionToolNames,
242
266
  modelRuntime,
243
267
  ...(settingsManager ? { settingsManager } : {}),
244
268
  ...(model ? { model } : {}),
@@ -247,6 +271,22 @@ async function createSdkSession(sdk, input, shared) {
247
271
  ? { customTools: input.customTools }
248
272
  : {}),
249
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
+ }
250
290
  const customToolNames = (input.customTools ?? [])
251
291
  .map((tool) => isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined)
252
292
  .filter((name) => Boolean(name));
@@ -259,7 +299,7 @@ async function createSdkSession(sdk, input, shared) {
259
299
  .map((tool) => typeof tool?.name === "string" ? tool.name : undefined)
260
300
  .filter((name) => Boolean(name)));
261
301
  for (const customToolName of customToolNames) {
262
- const expectedActive = input.toolNames.includes(customToolName);
302
+ const expectedActive = sessionToolNames.includes(customToolName);
263
303
  if (activeNames.has(customToolName) !== expectedActive) {
264
304
  throw new Error(`pi SDK custom-tool activation mismatch for ${customToolName}: expectedActive=${expectedActive}`);
265
305
  }
@@ -267,6 +307,47 @@ async function createSdkSession(sdk, input, shared) {
267
307
  }
268
308
  return created.session;
269
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
+ }
270
351
  /**
271
352
  * Throttle high-noise Pi SDK events from session-events.jsonl.
272
353
  * Always keeps tool lifecycle, turn/agent end, assistant messages, and errors.
@@ -404,25 +485,6 @@ function extractSdkUsageSample(event) {
404
485
  const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
405
486
  return responseKey ? { responseKey, tokens } : { tokens };
406
487
  }
407
- /** Extract a non-empty stop reason from a terminal SDK event.
408
- * Probes multiple field shapes across SDK versions so a missing field fails
409
- * open (undefined) rather than misclassifying an attempt. */
410
- function readStopReason(event) {
411
- const message = isRecord(event.message) ? event.message : undefined;
412
- const candidates = [
413
- message?.stop_reason,
414
- message?.stopReason,
415
- event.stopReason,
416
- event.stop_reason,
417
- ];
418
- for (const candidate of candidates) {
419
- if (typeof candidate === "string" &&
420
- candidate.trim().length > 0) {
421
- return candidate.trim();
422
- }
423
- }
424
- return undefined;
425
- }
426
488
  function aggregateSdkTokenUsage(samples) {
427
489
  const identified = new Map();
428
490
  let anonymousMaximum = 0;
@@ -444,6 +506,8 @@ function aggregateSdkTokenUsage(samples) {
444
506
  * When reuseScope is active, only shared auth/model resources are reused; each attempt still
445
507
  * creates and disposes its own session and resource loader. Session reuse across steps is
446
508
  * deferred until a proven reset/isolation strategy exists.
509
+ * A transport stall may abort the current prompt and send one continue on the
510
+ * same session; that is in-attempt recovery, not cross-step session reuse.
447
511
  */
448
512
  export async function executeSingleSdkAttempt(options) {
449
513
  const modelConfig = options.modelConfig;
@@ -494,7 +558,7 @@ export async function executeSingleSdkAttempt(options) {
494
558
  type === "message_end" ||
495
559
  type === "agent_end" ||
496
560
  type === "agent_settled") {
497
- const candidate = readStopReason(event);
561
+ const candidate = extractPiEventStopReason(event);
498
562
  if (candidate)
499
563
  stopReason = candidate;
500
564
  }
@@ -555,20 +619,37 @@ export async function executeSingleSdkAttempt(options) {
555
619
  ? { customTools: writerCustomTools }
556
620
  : {}),
557
621
  ...(requireWriterCustomTools ? { requireWriterCustomTools: true } : {}),
622
+ ...(options.piExtensionPaths && options.piExtensionPaths.length > 0
623
+ ? { additionalExtensionPaths: options.piExtensionPaths }
624
+ : {}),
558
625
  });
559
626
  let resolveStall;
560
627
  let resolveSettlement;
561
628
  let settled = false;
562
- const settlementPromise = new Promise((resolve) => {
563
- resolveSettlement = resolve;
564
- });
565
- const stallPromise = stallTimeoutMs > 0
566
- ? new Promise((resolve) => {
629
+ let abortConfirmed = true;
630
+ const persistRuntimeNote = (event) => {
631
+ const line = serializeSessionEvent(event);
632
+ stdoutPreview.append(`${line}\n`);
633
+ stdoutCollector.append(`${line}\n`);
634
+ sessionEventAppender?.append(line, event);
635
+ };
636
+ const resetSettlement = () => {
637
+ settled = false;
638
+ return new Promise((resolve) => {
639
+ resolveSettlement = resolve;
640
+ });
641
+ };
642
+ const resetStallPromise = () => {
643
+ if (stallTimeoutMs <= 0) {
644
+ resolveStall = undefined;
645
+ return null;
646
+ }
647
+ return new Promise((resolve) => {
567
648
  resolveStall = resolve;
568
- })
569
- : null;
649
+ });
650
+ };
570
651
  const armStallWatchdog = () => {
571
- if (!stallPromise || !resolveStall)
652
+ if (stallTimeoutMs <= 0 || !resolveStall)
572
653
  return;
573
654
  if (stallHandle)
574
655
  clearTimeout(stallHandle);
@@ -609,17 +690,6 @@ export async function executeSingleSdkAttempt(options) {
609
690
  stdoutCollector.append(`${line}\n`);
610
691
  sessionEventAppender?.append(line, event);
611
692
  });
612
- const filePrefix = options.attachedFiles
613
- .map((file) => `@${file}`)
614
- .join(" ");
615
- const promptMessage = filePrefix
616
- ? `${filePrefix}\n${options.userMessage}`
617
- : options.userMessage;
618
- const promptPromise = session.prompt(promptMessage);
619
- // A settlement event is permitted to win the race while prompt() remains
620
- // pending. Observe a later rejection so it never becomes unhandled.
621
- void promptPromise.catch(() => { });
622
- armStallWatchdog();
623
693
  const timeoutPromise = timeoutMs > 0
624
694
  ? new Promise((resolve) => {
625
695
  timeoutHandle = setTimeout(() => {
@@ -627,21 +697,79 @@ export async function executeSingleSdkAttempt(options) {
627
697
  }, timeoutMs);
628
698
  })
629
699
  : null;
630
- const raced = await Promise.race([
631
- promptPromise.then(() => "done"),
632
- settlementPromise,
633
- ...(timeoutPromise ? [timeoutPromise] : []),
634
- ...(stallPromise ? [stallPromise] : []),
635
- ]);
636
- if (raced !== "done" && raced !== "settled") {
637
- timedOut = true;
638
- appendStderr(raced === "stall"
639
- ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
640
- : `pi SDK step timed out after ${timeoutMs}ms`);
641
- const abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
700
+ const runTurn = async (userMessage) => {
701
+ const settlementPromise = resetSettlement();
702
+ const stallPromise = resetStallPromise();
703
+ const promptPromise = session.prompt(userMessage);
704
+ // A settlement event is permitted to win the race while prompt() remains
705
+ // pending. Observe a later rejection so it never becomes unhandled.
706
+ void promptPromise.catch(() => { });
707
+ armStallWatchdog();
708
+ return Promise.race([
709
+ promptPromise.then(() => "done"),
710
+ settlementPromise,
711
+ ...(timeoutPromise ? [timeoutPromise] : []),
712
+ ...(stallPromise ? [stallPromise] : []),
713
+ ]);
714
+ };
715
+ const disposeSession = async () => {
642
716
  disposeAttempted = true;
643
717
  const disposeConfirmed = await runSessionActionWithGrace("dispose", () => session.dispose());
644
718
  terminationConfirmed = abortConfirmed && disposeConfirmed;
719
+ };
720
+ const failClosedAfterWait = async (reason, extraStderr, abortFirst = true) => {
721
+ timedOut = true;
722
+ appendStderr(reason === "stall"
723
+ ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
724
+ : `pi SDK step timed out after ${timeoutMs}ms`);
725
+ if (extraStderr)
726
+ appendStderr(extraStderr);
727
+ if (abortFirst) {
728
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
729
+ }
730
+ await disposeSession();
731
+ };
732
+ const filePrefix = options.attachedFiles
733
+ .map((file) => `@${file}`)
734
+ .join(" ");
735
+ const promptMessage = filePrefix
736
+ ? `${filePrefix}\n${options.userMessage}`
737
+ : options.userMessage;
738
+ let continuesSent = 0;
739
+ let raced = await runTurn(promptMessage);
740
+ if (raced === "stall") {
741
+ abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
742
+ const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
743
+ const canContinue = abortConfirmed &&
744
+ writeToolCallCount === 0 &&
745
+ withinAbsoluteTimeout &&
746
+ continuesSent < PI_SDK_STALL_CONTINUE_MAX;
747
+ if (canContinue) {
748
+ // The aborted turn's stopReason must not leak onto a recovered continue.
749
+ stopReason = undefined;
750
+ continuesSent += 1;
751
+ persistRuntimeNote({
752
+ type: "loop-agent-stall-continue",
753
+ prompt: PI_SDK_STALL_CONTINUE_PROMPT,
754
+ maxContinues: PI_SDK_STALL_CONTINUE_MAX,
755
+ });
756
+ raced = await runTurn(PI_SDK_STALL_CONTINUE_PROMPT);
757
+ if (raced !== "done" && raced !== "settled") {
758
+ await failClosedAfterWait(raced === "stall" ? "stall" : "absolute-timeout", raced === "stall"
759
+ ? "pi SDK stall continue did not recover; treating as timeout"
760
+ : "pi SDK stall continue hit the absolute timeout", true);
761
+ }
762
+ }
763
+ else {
764
+ await failClosedAfterWait("stall", !abortConfirmed
765
+ ? undefined
766
+ : writeToolCallCount > 0
767
+ ? "pi SDK stall continue skipped because write tools already ran"
768
+ : "pi SDK stall continue skipped because absolute timeout elapsed", false);
769
+ }
770
+ }
771
+ else if (raced === "absolute-timeout") {
772
+ await failClosedAfterWait("absolute-timeout");
645
773
  }
646
774
  }
647
775
  catch (error) {
@@ -15,6 +15,8 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
15
15
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
16
16
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
17
17
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
18
+ import { copyFrontendTestStandardScenarios } from "../workflows/dag/frontend-test-standard-scenarios.js";
19
+ import { probeFrontendTestEnvironment } from "../workflows/dag/frontend-test-environment-probe.js";
18
20
  import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
19
21
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
20
22
  import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
@@ -2061,6 +2063,52 @@ async function executeFrontendTestHtmlReport(input, meta) {
2061
2063
  };
2062
2064
  }
2063
2065
  }
2066
+ async function executeFrontendTestStandardScenarios(input) {
2067
+ const started = Date.now();
2068
+ try {
2069
+ const result = await copyFrontendTestStandardScenarios({
2070
+ workspaceRoot: input.cwd,
2071
+ });
2072
+ return {
2073
+ ok: true,
2074
+ stdout: JSON.stringify(result),
2075
+ stderr: "",
2076
+ failureCategory: "success",
2077
+ durationMs: Date.now() - started,
2078
+ };
2079
+ }
2080
+ catch (error) {
2081
+ return {
2082
+ ok: false,
2083
+ stdout: "",
2084
+ stderr: error instanceof Error ? error.message : String(error),
2085
+ failureCategory: "invalid-output",
2086
+ durationMs: Date.now() - started,
2087
+ };
2088
+ }
2089
+ }
2090
+ async function executeFrontendTestEnvironmentProbe(input) {
2091
+ const started = Date.now();
2092
+ const result = await probeFrontendTestEnvironment({
2093
+ workspaceRoot: input.cwd,
2094
+ });
2095
+ if (result.ok) {
2096
+ return {
2097
+ ok: true,
2098
+ stdout: result.stdout,
2099
+ stderr: "",
2100
+ failureCategory: "success",
2101
+ durationMs: Date.now() - started,
2102
+ };
2103
+ }
2104
+ return {
2105
+ ok: false,
2106
+ stdout: "",
2107
+ stderr: result.stderr,
2108
+ failureCategory: "nonzero-exit",
2109
+ durationMs: Date.now() - started,
2110
+ };
2111
+ }
2064
2112
  async function executeFrontendTestEvidenceValidation(input) {
2065
2113
  const started = Date.now();
2066
2114
  try {
@@ -2339,6 +2387,12 @@ export async function executeDagShellNode(input, meta) {
2339
2387
  };
2340
2388
  }
2341
2389
  }
2390
+ if (shell?.frontendTestStandardScenarios) {
2391
+ return executeFrontendTestStandardScenarios(input);
2392
+ }
2393
+ if (shell?.frontendTestEnvironmentProbe) {
2394
+ return executeFrontendTestEnvironmentProbe(input);
2395
+ }
2342
2396
  if (shell?.frontendBrowserToolPreflight) {
2343
2397
  const started = Date.now();
2344
2398
  try {
@@ -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;
@@ -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} 条协议事件`;