@piagent/platform 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +9 -8
  3. package/README.vi.md +9 -5
  4. package/adapters/web-frontend/profile.json +2 -4
  5. package/architecture/layers.json +18 -3
  6. package/benchmarks/deep-logic-v1/SPECIALIST_PROTOCOL.vi.md +122 -0
  7. package/benchmarks/deep-logic-v1/grade.mjs +272 -10
  8. package/benchmarks/deep-logic-v1/project/apps/admin/src/billing-summary.js +3 -0
  9. package/benchmarks/deep-logic-v1/project/benchmark-contract/public-smoke.test.js +166 -0
  10. package/benchmarks/deep-logic-v1/project/package.json +1 -1
  11. package/benchmarks/deep-logic-v1/project/packages/billing/src/close-period.js +3 -0
  12. package/benchmarks/deep-logic-v1/project/packages/billing/src/plan-timeline.js +3 -0
  13. package/benchmarks/deep-logic-v1/project/test/smoke.test.js +7 -1
  14. package/benchmarks/deep-logic-v1/prompts/budgeted-context-graph.md +2 -0
  15. package/benchmarks/deep-logic-v1/prompts/fair-dependency-scheduler.md +2 -0
  16. package/benchmarks/deep-logic-v1/prompts/layered-policy-resolution.md +2 -0
  17. package/benchmarks/deep-logic-v1/prompts/resumable-stream-assembly.md +3 -1
  18. package/benchmarks/deep-logic-v1/prompts/revision-event-reconciliation.md +2 -0
  19. package/benchmarks/deep-logic-v1/prompts/temporal-usage-billing-close.md +123 -0
  20. package/benchmarks/deep-logic-v1/prompts/transactional-config-merge.md +2 -0
  21. package/benchmarks/deep-logic-v1/suite.json +51 -3
  22. package/benchmarks/deep-logic-v1/variant.mjs +22 -1
  23. package/benchmarks/production-v1/spend-control.v1.json +68 -0
  24. package/benchmarks/production-v1/suite.json +42 -3
  25. package/catalog/capabilities.json +6 -6
  26. package/docs/command-reference-vietnamese.md +6 -6
  27. package/docs/context-engine.md +7 -1
  28. package/docs/distribution-standard.md +7 -7
  29. package/docs/en/architecture.md +1 -0
  30. package/docs/languages.json +4 -4
  31. package/docs/model-options.md +8 -5
  32. package/docs/operator-manual-vietnamese.md +13 -11
  33. package/docs/project-adapters.md +2 -2
  34. package/docs/publishing-for-teams.md +9 -0
  35. package/docs/quality-benchmark.md +271 -62
  36. package/docs/release-install-policy.md +10 -9
  37. package/docs/runtime-policy-design.md +4 -4
  38. package/docs/runtime-quality-baseline.md +13 -6
  39. package/docs/security-threat-model.md +2 -2
  40. package/docs/task-lifecycle-vietnamese.md +2 -2
  41. package/docs/team-onboarding.md +2 -2
  42. package/docs/usage-observability.md +7 -2
  43. package/docs/vi/architecture.md +1 -0
  44. package/docs-site/architecture.html +1 -1
  45. package/docs-site/benchmark.html +1 -1
  46. package/docs-site/commands.html +1 -1
  47. package/docs-site/context-engine.html +1 -1
  48. package/docs-site/en/architecture.html +1 -1
  49. package/docs-site/en/benchmark.html +1 -1
  50. package/docs-site/en/commands.html +1 -1
  51. package/docs-site/en/context-engine.html +1 -1
  52. package/docs-site/en/index.html +1 -1
  53. package/docs-site/en/mcp-approval.html +1 -1
  54. package/docs-site/en/mcp-auth.html +1 -1
  55. package/docs-site/en/mcp-servers.html +1 -1
  56. package/docs-site/en/mcp.html +1 -1
  57. package/docs-site/en/permissions.html +1 -1
  58. package/docs-site/en/profiles.html +1 -1
  59. package/docs-site/en/quickstart.html +1 -1
  60. package/docs-site/en/runtime.html +1 -1
  61. package/docs-site/en/scope.html +1 -1
  62. package/docs-site/en/security.html +1 -1
  63. package/docs-site/en/team.html +1 -1
  64. package/docs-site/en/whats-new.html +1 -1
  65. package/docs-site/en/workflows.html +1 -1
  66. package/docs-site/index.html +1 -1
  67. package/docs-site/mcp-approval.html +1 -1
  68. package/docs-site/mcp-auth.html +1 -1
  69. package/docs-site/mcp-servers.html +1 -1
  70. package/docs-site/mcp.html +1 -1
  71. package/docs-site/permissions.html +1 -1
  72. package/docs-site/profiles.html +1 -1
  73. package/docs-site/quickstart.html +1 -1
  74. package/docs-site/runtime.html +1 -1
  75. package/docs-site/scope.html +1 -1
  76. package/docs-site/security.html +1 -1
  77. package/docs-site/team.html +1 -1
  78. package/docs-site/whats-new.html +1 -1
  79. package/docs-site/workflows.html +1 -1
  80. package/evals/fixtures/benchmark-assurance-evidence.invalid.json +1 -1
  81. package/evals/fixtures/benchmark-assurance-evidence.valid.json +1 -1
  82. package/evals/fixtures/piagent-webui/session-live-state-v1.invalid.json +18 -0
  83. package/evals/fixtures/piagent-webui/session-live-state-v1.valid.json +27 -0
  84. package/evals/fs4-readiness-matrix.v1.json +2 -2
  85. package/evals/fs5-causal-arm.v1.json +2 -2
  86. package/evals/fs5-pilot-protocol.v1.json +2 -2
  87. package/evals/fs5-pilot-protocol.v4.json +6 -5
  88. package/evals/fs5-pilot-protocol.v5.json +7 -6
  89. package/evals/private-holdout-v1/public-exposure.v1.json +1 -1
  90. package/package.json +3 -1
  91. package/packages/piagent-core/benchmark/benchmark-assurance.js +4 -1
  92. package/packages/piagent-core/benchmark/benchmark-bootstrap.js +91 -9
  93. package/packages/piagent-core/benchmark/benchmark-claim-restrictions.js +38 -7
  94. package/packages/piagent-core/benchmark/benchmark-cli.js +7 -7
  95. package/packages/piagent-core/benchmark/benchmark-codex.js +3 -1
  96. package/packages/piagent-core/benchmark/benchmark-comparison.js +271 -2
  97. package/packages/piagent-core/benchmark/benchmark-core.js +732 -661
  98. package/packages/piagent-core/benchmark/benchmark-execution-guard.js +7 -5
  99. package/packages/piagent-core/benchmark/benchmark-forensics.js +439 -4
  100. package/packages/piagent-core/benchmark/benchmark-host-readiness.js +639 -0
  101. package/packages/piagent-core/benchmark/benchmark-matrix.js +22 -4
  102. package/packages/piagent-core/benchmark/benchmark-normalized-cost.js +317 -0
  103. package/packages/piagent-core/benchmark/benchmark-preflight.js +3 -1
  104. package/packages/piagent-core/benchmark/benchmark-process.js +6 -5
  105. package/packages/piagent-core/benchmark/benchmark-provider-wire.js +126 -0
  106. package/packages/piagent-core/benchmark/benchmark-record-validation.js +183 -0
  107. package/packages/piagent-core/benchmark/benchmark-report.js +116 -12
  108. package/packages/piagent-core/benchmark/benchmark-resume-recovery.js +14 -0
  109. package/packages/piagent-core/benchmark/benchmark-runner-policy.js +26 -0
  110. package/packages/piagent-core/benchmark/benchmark-stage-diagnostic.js +782 -0
  111. package/packages/piagent-core/benchmark/benchmark-statistics.js +64 -0
  112. package/packages/piagent-core/benchmark/benchmark-stop-policy.js +8 -5
  113. package/packages/piagent-core/benchmark/benchmark-suite-identity.js +31 -0
  114. package/packages/piagent-core/benchmark/benchmark-suite-runtime.js +13 -9
  115. package/packages/piagent-core/benchmark/benchmark-suite.js +128 -2
  116. package/packages/piagent-core/benchmark/benchmark-summary-support.js +376 -0
  117. package/packages/piagent-core/benchmark/benchmark-timing-diagnostics.js +971 -0
  118. package/packages/piagent-core/benchmark/benchmark-usage.js +311 -0
  119. package/packages/piagent-core/benchmark/benchmark-workflow.js +166 -0
  120. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +1080 -0
  121. package/packages/piagent-core/extensions/acceptance-contract-semantics.js +83 -69
  122. package/packages/piagent-core/extensions/acceptance-error-classes.js +160 -0
  123. package/packages/piagent-core/extensions/acceptance-executable-evidence.js +12 -9
  124. package/packages/piagent-core/extensions/acceptance-receipt.js +34 -11
  125. package/packages/piagent-core/extensions/acceptance-state-machine-evidence.js +518 -0
  126. package/packages/piagent-core/extensions/acceptance-test-binding-evidence.js +60 -0
  127. package/packages/piagent-core/extensions/context-efficiency-metrics.js +302 -0
  128. package/packages/piagent-core/extensions/context-efficiency-report.js +167 -0
  129. package/packages/piagent-core/extensions/context-engine.js +148 -145
  130. package/packages/piagent-core/extensions/context-evidence.js +48 -0
  131. package/packages/piagent-core/extensions/context-import-links.js +296 -0
  132. package/packages/piagent-core/extensions/context-telemetry-integrity.js +65 -0
  133. package/packages/piagent-core/extensions/criterion-context-pack.js +422 -7
  134. package/packages/piagent-core/extensions/criterion-graph.js +78 -8
  135. package/packages/piagent-core/extensions/guard-types.ts +3 -0
  136. package/packages/piagent-core/extensions/javascript-regex-evidence.js +32 -0
  137. package/packages/piagent-core/extensions/piagent-guard.ts +250 -43
  138. package/packages/piagent-core/extensions/policy-core.js +1 -1
  139. package/packages/piagent-core/extensions/task-contract-view.js +17 -3
  140. package/packages/piagent-core/extensions/task-lifecycle.js +7 -2
  141. package/packages/piagent-core/extensions/task-state.js +40 -134
  142. package/packages/piagent-core/extensions/verification-intelligence.js +3 -1
  143. package/packages/piagent-core/extensions/workspace-evidence-roots.js +157 -0
  144. package/packages/piagent-core/package.json +1 -1
  145. package/packages/piagent-core/policies/base-policy.json +3 -0
  146. package/packages/piagent-core/runtime/context/context-delivery.ts +103 -0
  147. package/packages/piagent-core/runtime/context/context-delta-shadow.ts +53 -0
  148. package/packages/piagent-core/runtime/context/context-efficiency-format.ts +47 -0
  149. package/packages/piagent-core/runtime/context/context-evidence-qualification.ts +32 -0
  150. package/packages/piagent-core/runtime/context/prefix-telemetry.ts +47 -0
  151. package/packages/piagent-core/runtime/context/repository-memory-hints.ts +27 -0
  152. package/packages/piagent-core/runtime/hooks/agent-prompt-telemetry.ts +56 -0
  153. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +162 -102
  154. package/packages/piagent-core/runtime/hooks/completion-hook.ts +5 -6
  155. package/packages/piagent-core/runtime/hooks/input-hook.ts +11 -1
  156. package/packages/piagent-core/runtime/hooks/session-hooks.ts +22 -2
  157. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +7 -2
  158. package/packages/piagent-core/runtime/hooks/tool-result-content-guards.ts +95 -0
  159. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +84 -102
  160. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +9 -3
  161. package/packages/piagent-core/runtime/inspection/activity-recovery.ts +104 -0
  162. package/packages/piagent-core/runtime/inspection/approval-broker.ts +29 -5
  163. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +1 -1
  164. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +1 -1
  165. package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +53 -0
  166. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +34 -6
  167. package/packages/piagent-core/runtime/model/authenticated-catalog.ts +17 -9
  168. package/packages/piagent-core/runtime/model/openai-codex-reasoning.ts +83 -0
  169. package/packages/piagent-core/runtime/model/provider-wire-fingerprint.ts +238 -0
  170. package/packages/piagent-core/runtime/orchestration/role-policy.ts +1 -1
  171. package/packages/piagent-core/runtime/product/activity-inspector.ts +7 -2
  172. package/packages/piagent-core/runtime/product/efficiency-metrics.ts +4 -2
  173. package/packages/piagent-core/runtime/quality/exact-output-contract.ts +2 -1
  174. package/packages/piagent-core/runtime/quality/performance-assurance-signals.ts +23 -0
  175. package/packages/piagent-core/runtime/quality/performance-assurance.ts +6 -2
  176. package/packages/piagent-core/runtime/recovery/edit-recovery-context.ts +113 -0
  177. package/packages/piagent-core/runtime/recovery/edit-recovery-delivery.ts +42 -0
  178. package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +55 -2
  179. package/packages/piagent-core/runtime/recovery/semantic-repair-runtime.ts +55 -2
  180. package/packages/piagent-core/runtime/registration/context-commands.ts +66 -5
  181. package/packages/piagent-core/runtime/registration/policy-tools.ts +46 -11
  182. package/packages/piagent-core/runtime/registration/task-completion-tools.ts +49 -16
  183. package/packages/piagent-core/runtime/registration/task-start-guidance.ts +163 -0
  184. package/packages/piagent-core/runtime/registration/task-start-retry-helpers.ts +37 -0
  185. package/packages/piagent-core/runtime/registration/task-start-tool.ts +67 -62
  186. package/packages/piagent-core/runtime/session/runtime-state.ts +154 -2
  187. package/packages/piagent-core/runtime/session/system-prompt.ts +4 -4
  188. package/packages/piagent-core/runtime/tools/apply-patch-tool.ts +500 -0
  189. package/packages/piagent-core/runtime/trajectory/trajectory-runtime.ts +2 -1
  190. package/packages/piagent-core/runtime/workflows/task-intake.ts +43 -2
  191. package/packages/piagent-core/security/sensitive-data.js +13 -218
  192. package/packages/piagent-core/security/sensitive-project-file.js +42 -0
  193. package/packages/piagent-core/security/sensitive-source-expression.js +330 -0
  194. package/packages/piagent-core/security/sensitive-source-formats.js +394 -0
  195. package/packages/piagent-core/security/sensitive-text.js +234 -0
  196. package/packages/piagent-core/subagents/piagent-worker.md +1 -1
  197. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-rD9WJKxs.js → MarkdownMessage-CxeZqTte.js} +1 -1
  198. package/packages/piagent-webui/dist/client/assets/index-DynIMe8I.js +176 -0
  199. package/packages/piagent-webui/dist/client/index.html +1 -1
  200. package/packages/piagent-webui/gateway/gateway-events.ts +109 -5
  201. package/packages/piagent-webui/gateway/gateway-service.ts +3 -0
  202. package/packages/piagent-webui/gateway/gateway-session-stream.ts +93 -7
  203. package/packages/piagent-webui/gateway/session-command-controller.ts +18 -3
  204. package/packages/piagent-webui/gateway/session-effective-options.ts +47 -0
  205. package/packages/piagent-webui/gateway/session-live-state.ts +21 -0
  206. package/packages/piagent-webui/gateway/session-operation-start.ts +5 -1
  207. package/packages/piagent-webui/gateway/session-operation-watchdog.ts +158 -0
  208. package/packages/piagent-webui/gateway/session-runtime-factory.ts +45 -0
  209. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +139 -120
  210. package/packages/piagent-webui/server/loopback-server.ts +8 -0
  211. package/packages/piagent-webui/server/transcript-projection.ts +22 -4
  212. package/packages/piagent-webui/shared/text-visibility.ts +10 -0
  213. package/schemas/piagent-webui/catalog-v1.json +9 -0
  214. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +53 -3
  215. package/schemas/piagent-webui/session-live-state-v1.schema.json +76 -0
  216. package/schemas/task-contract.schema.json +12 -1
  217. package/scripts/benchmark-runner-core.mjs +355 -359
  218. package/scripts/benchmark-runner-finalization.mjs +309 -0
  219. package/scripts/benchmark-runner-host-readiness.mjs +94 -0
  220. package/scripts/benchmark-runner-support.mjs +200 -0
  221. package/scripts/benchmark-session.mjs +95 -41
  222. package/scripts/configure-model-scope.sh +4 -3
  223. package/scripts/helper-evaluation.mjs +1 -1
  224. package/scripts/install-global.sh +2 -2
  225. package/scripts/pi-usage-history.mjs +37 -10
  226. package/scripts/piagent-dashboard.mjs +13 -1
  227. package/scripts/setup.sh +2 -2
  228. package/scripts/verify-local.sh +5 -1
  229. package/templates/global/settings.json +2 -1
  230. package/packages/piagent-webui/dist/client/assets/index-BZRFw1u6.js +0 -174
@@ -0,0 +1,95 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { matchesProtectedPath } from "../../extensions/policy-core.js";
5
+
6
+ function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
7
+ if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
8
+ let raw = candidate.trim();
9
+ try {
10
+ raw = decodeURIComponent(raw);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
15
+ if (!raw.toLowerCase().startsWith("file://")) return undefined;
16
+ try {
17
+ raw = fileURLToPath(raw);
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
23
+ return path.relative(cwd, absolute).split(path.sep).join("/");
24
+ }
25
+
26
+ function filterTextBlocks(
27
+ content: unknown,
28
+ blocked: (line: string) => boolean,
29
+ emptyText: string,
30
+ noticeText: (count: number) => string
31
+ ): { changed: boolean; content?: unknown; redactedLines: number } {
32
+ if (!Array.isArray(content)) return { changed: false, redactedLines: 0 };
33
+ let changed = false;
34
+ let redactedLines = 0;
35
+ const filtered = content.map((block) => {
36
+ if (!block || typeof block !== "object") return block;
37
+ const text = (block as { type?: unknown; text?: unknown }).text;
38
+ if ((block as { type?: unknown }).type !== "text" || typeof text !== "string") return block;
39
+ const kept: string[] = [];
40
+ let blockRedactedLines = 0;
41
+ for (const line of text.split(/\r?\n/)) {
42
+ if (blocked(line)) {
43
+ changed = true;
44
+ redactedLines += 1;
45
+ blockRedactedLines += 1;
46
+ } else {
47
+ kept.push(line);
48
+ }
49
+ }
50
+ if (blockRedactedLines === 0) return block;
51
+ const notice = noticeText(blockRedactedLines);
52
+ const nextText = kept.join("\n").trim().length > 0
53
+ ? `${kept.join("\n")}\n${notice}`
54
+ : `${emptyText}\n${notice}`;
55
+ return { ...block, text: nextText };
56
+ });
57
+ return { changed, content: filtered, redactedLines };
58
+ }
59
+
60
+ export function filterGrepProtectedContent(content: unknown, protectedPatterns: string[]) {
61
+ return filterTextBlocks(
62
+ content,
63
+ (line) => {
64
+ const linePath = line.match(/^(.+?)(?::\d+:|-\d+-)/)?.[1];
65
+ return Boolean(linePath && matchesProtectedPath(linePath, protectedPatterns));
66
+ },
67
+ "No matches found in non-protected paths.",
68
+ (count) => `[Piagent Pi guard redacted ${count} protected grep line${count === 1 ? "" : "s"}.]`
69
+ );
70
+ }
71
+
72
+ export function filterProtectedPathListContent(
73
+ cwd: string,
74
+ content: unknown,
75
+ protectedPatterns: string[],
76
+ basePath: string,
77
+ toolName: string
78
+ ) {
79
+ return filterTextBlocks(
80
+ content,
81
+ (line) => {
82
+ const trimmed = line.trim();
83
+ if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("No ") || trimmed === "(empty directory)") return false;
84
+ const entry = trimmed.replace(/[\\/]+$/, "");
85
+ const candidates = new Set<string>();
86
+ const direct = normalizeRelative(cwd, entry);
87
+ const underBase = normalizeRelative(cwd, path.posix.join(basePath || ".", entry));
88
+ if (direct !== undefined) candidates.add(direct);
89
+ if (underBase !== undefined) candidates.add(underBase);
90
+ return [...candidates].some((candidate) => matchesProtectedPath(candidate, protectedPatterns));
91
+ },
92
+ "No entries found in non-protected paths.",
93
+ (count) => `[Piagent Pi guard redacted ${count} protected ${toolName} line${count === 1 ? "" : "s"}.]`
94
+ );
95
+ }
@@ -1,9 +1,6 @@
1
- import path from "node:path";
2
- import { fileURLToPath } from "node:url";
3
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
2
 
5
- import { toolResultFingerprint } from "../../extensions/context-engine.js";
6
- import type { TaskContract } from "../../extensions/guard-types.ts";
3
+ import { estimateContextTokens, toolResultFingerprint } from "../../extensions/context-engine.js";
7
4
  import { matchesProtectedPath } from "../../extensions/policy-core.js";
8
5
  import { changedSnapshotFiles, taskDeltaFilesFromSnapshot } from "../../extensions/task-contract-view.js";
9
6
  import { classifyVerificationFailure } from "../../extensions/verification-intelligence.js";
@@ -11,25 +8,29 @@ import { redactForStorage, redactSensitiveText } from "../../extensions/redactio
11
8
  import { appendObservedBashResult, hashEvidenceCommand, observedBashResultFromToolResultEvent } from "../../extensions/runtime-evidence.js";
12
9
  import { workingTreeSnapshot, workingTreeSnapshotHasUnavailableEvidence } from "../../extensions/task-state.js";
13
10
  import { workingTreeObservation } from "../../extensions/working-tree-digest.js";
11
+ import { recordObservedContextEvidence } from "../context/context-evidence-qualification.ts";
12
+ import { confirmContextDeliveryFromToolResult, type ContextDeliveryConfirmationDependencies } from "../context/context-delivery.ts";
14
13
  import { recordMutationResult } from "../inspection/mutation-provenance-recorder.ts";
14
+ import { classifyToolFailure } from "../inspection/tool-failure-classification.ts";
15
15
  import { boundedGitDiffReview } from "../quality/performance-assurance.ts";
16
16
  import { currentFileContentDigests } from "../quality/model-mutation-proof.ts";
17
17
  import { boundedPerformanceReviewResultText } from "../quality/performance-review-evidence.ts";
18
+ import { buildEditRecoveryContext, type EditRecoveryContext } from "../recovery/edit-recovery-context.ts";
19
+ import { EditRecoveryDeliveryState } from "../recovery/edit-recovery-delivery.ts";
18
20
  import { attachToolResultCompactionDetails, compactToolResultDetails, compactToolResultTextContent, type ToolResultCaptureSummary } from "../session/tool-result-compaction.ts";
19
- import { RuntimeSessionState, type ObservedTaskContext } from "../session/runtime-state.ts";
21
+ import type { ObservedTaskContext } from "../session/runtime-state.ts";
20
22
  import { observeTrajectorySync } from "../trajectory/trajectory-observability.ts";
21
23
  import type { TrajectorySyncResult } from "../trajectory/trajectory-runtime.ts";
24
+ import { filterGrepProtectedContent, filterProtectedPathListContent } from "./tool-result-content-guards.ts";
22
25
  import { patchLineStats } from "./tool-result-metadata.ts";
23
26
 
27
+ export { filterGrepProtectedContent, filterProtectedPathListContent };
24
28
  type ToolResultEvent = { toolCallId?: string; toolName: string; input?: unknown; content?: unknown; details?: unknown; isError?: boolean; usage?: unknown };
25
29
  type ObservedBashResult = NonNullable<ReturnType<typeof observedBashResultFromToolResultEvent>>;
26
30
  type ObservedVerificationResult = ObservedBashResult & { outputText?: string };
27
31
  type WorkingTreeObservation = ReturnType<typeof workingTreeObservation>;
28
32
 
29
- type ToolResultHookDependencies = {
30
- state: RuntimeSessionState;
31
- activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
32
- maxManifestFiles: number;
33
+ type ToolResultHookDependencies = ContextDeliveryConfirmationDependencies & {
33
34
  readProtectedPaths: (ctx: ExtensionContext) => string[];
34
35
  recordObservedBash: (observed: ObservedBashResult) => void;
35
36
  observedBashLedgerPath: (cwd: string) => string;
@@ -69,33 +70,13 @@ type ToolResultHookDependencies = {
69
70
  success: boolean;
70
71
  exitCode: number;
71
72
  currentWorkingTreeDigest: string;
72
- changedPaths: string[];
73
+ changedPaths: string[]; authoredChangedPaths: string[];
73
74
  retryableFailure: boolean; correctiveFailure: boolean;
74
75
  }
75
76
  ) => TrajectorySyncResult | undefined;
76
77
  syncTrajectory?: (ctx: ExtensionContext, contextObserved: boolean) => TrajectorySyncResult | undefined;
77
78
  };
78
79
 
79
- function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
80
- if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
81
- let raw = candidate.trim();
82
- try {
83
- raw = decodeURIComponent(raw);
84
- } catch {
85
- return undefined;
86
- }
87
- if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
88
- if (!raw.toLowerCase().startsWith("file://")) return undefined;
89
- try {
90
- raw = fileURLToPath(raw);
91
- } catch {
92
- return undefined;
93
- }
94
- }
95
- const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
96
- return path.relative(cwd, absolute).split(path.sep).join("/");
97
- }
98
-
99
80
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
100
81
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
101
82
  const prototype = Object.getPrototypeOf(value);
@@ -154,79 +135,19 @@ function redactToolResultTextContent(content: unknown): { content: unknown; reda
154
135
  return { content: safeContent, redacted };
155
136
  }
156
137
 
157
- function filterTextBlocks(
158
- content: unknown,
159
- blocked: (line: string) => boolean,
160
- emptyText: string,
161
- noticeText: (count: number) => string
162
- ): { changed: boolean; content?: unknown; redactedLines: number } {
163
- if (!Array.isArray(content)) return { changed: false, redactedLines: 0 };
164
- let changed = false;
165
- let redactedLines = 0;
166
- const filtered = content.map((block) => {
167
- if (!block || typeof block !== "object") return block;
168
- const text = (block as { type?: unknown; text?: unknown }).text;
169
- if ((block as { type?: unknown }).type !== "text" || typeof text !== "string") return block;
170
- const kept: string[] = [];
171
- let blockRedactedLines = 0;
172
- for (const line of text.split(/\r?\n/)) {
173
- if (blocked(line)) {
174
- changed = true;
175
- redactedLines += 1;
176
- blockRedactedLines += 1;
177
- } else {
178
- kept.push(line);
179
- }
180
- }
181
- if (blockRedactedLines === 0) return block;
182
- const notice = noticeText(blockRedactedLines);
183
- const nextText = kept.join("\n").trim().length > 0
184
- ? `${kept.join("\n")}\n${notice}`
185
- : `${emptyText}\n${notice}`;
186
- return { ...block, text: nextText };
187
- });
188
- return { changed, content: filtered, redactedLines };
189
- }
190
-
191
- export function filterGrepProtectedContent(content: unknown, protectedPatterns: string[]) {
192
- return filterTextBlocks(
193
- content,
194
- (line) => {
195
- const linePath = line.match(/^(.+?)(?::\d+:|-\d+-)/)?.[1];
196
- return Boolean(linePath && matchesProtectedPath(linePath, protectedPatterns));
197
- },
198
- "No matches found in non-protected paths.",
199
- (count) => `[Piagent Pi guard redacted ${count} protected grep line${count === 1 ? "" : "s"}.]`
200
- );
201
- }
202
-
203
- export function filterProtectedPathListContent(
204
- cwd: string,
205
- content: unknown,
206
- protectedPatterns: string[],
207
- basePath: string,
208
- toolName: string
209
- ) {
210
- return filterTextBlocks(
211
- content,
212
- (line) => {
213
- const trimmed = line.trim();
214
- if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("No ") || trimmed === "(empty directory)") return false;
215
- const entry = trimmed.replace(/[\\/]+$/, "");
216
- const candidates = new Set<string>();
217
- const direct = normalizeRelative(cwd, entry);
218
- const underBase = normalizeRelative(cwd, path.posix.join(basePath || ".", entry));
219
- if (direct !== undefined) candidates.add(direct);
220
- if (underBase !== undefined) candidates.add(underBase);
221
- return [...candidates].some((candidate) => matchesProtectedPath(candidate, protectedPatterns));
222
- },
223
- "No entries found in non-protected paths.",
224
- (count) => `[Piagent Pi guard redacted ${count} protected ${toolName} line${count === 1 ? "" : "s"}.]`
225
- );
138
+ function appendToolResultText(content: unknown, text: string): unknown[] {
139
+ const block = { type: "text", text };
140
+ if (Array.isArray(content)) return [...content, block];
141
+ if (typeof content === "string" && content) return [{ type: "text", text: content }, block];
142
+ return [block];
226
143
  }
227
144
 
228
145
  export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResultHookDependencies): void {
146
+ const editRecoveryDelivery = new EditRecoveryDeliveryState();
147
+ pi.on("session_compact", async (_event, ctx) => editRecoveryDelivery.advanceEpoch(ctx));
148
+ pi.on("session_shutdown", async (_event, ctx) => editRecoveryDelivery.clearSession(ctx));
229
149
  pi.on("tool_result", async (event, ctx) => {
150
+ confirmContextDeliveryFromToolResult(pi, ctx, event, dependencies);
230
151
  const taskIdentity = dependencies.state.taskIdentity(ctx);
231
152
  const resultToolCallId = event.toolCallId ?? toolResultFingerprint(event.toolName, event.input, []).key;
232
153
  const readProtectedPaths = dependencies.readProtectedPaths(ctx);
@@ -245,8 +166,8 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
245
166
  }
246
167
 
247
168
  const observedContextEntry = dependencies.observedTaskContext(ctx.cwd, event, readProtectedPaths);
248
- if (observedContextEntry) dependencies.state.rememberObservedContext(ctx, observedContextEntry);
249
- const pendingContext = dependencies.state.observedContext(ctx);
169
+ recordObservedContextEvidence(ctx, observedContextEntry, taskIdentity, dependencies);
170
+ const pendingContext = dependencies.state.qualifiedTaskContext(ctx);
250
171
  const shellSnapshotBefore = dependencies.isShellTool(event.toolName)
251
172
  ? dependencies.state.consumeShellMutationSnapshot(ctx, event.toolName, event.input)
252
173
  : undefined;
@@ -303,6 +224,7 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
303
224
  }
304
225
  }
305
226
  const outputText = boundedToolResultText(event.content);
227
+ const failureReasonCode = classifyToolFailure(event.toolName, event.isError === true, event.content, event.input);
306
228
  const performanceReviewOutputText = boundedPerformanceReviewResultText(event.content);
307
229
  const observedExitCode = observed?.exitCode
308
230
  ?? numericExitCode(isPlainRecord(event.details) ? event.details.exitCode ?? event.details.status : undefined)
@@ -326,6 +248,7 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
326
248
  exitCode: observedExitCode,
327
249
  currentWorkingTreeDigest: currentTreeDigest,
328
250
  changedPaths: [...new Set([...directMutationResult.changedPaths, ...shellChangedPaths])].sort(),
251
+ authoredChangedPaths: Object.keys(directMutationResult.recordedDigests).sort(),
329
252
  retryableFailure: failure?.retryable === true, correctiveFailure: failure?.sourceMutationPermission === "eligible-in-scope" && failure?.confidence === "high"
330
253
  })
331
254
  : undefined;
@@ -389,6 +312,8 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
389
312
  let resultContent: unknown = event.content;
390
313
  let resultDetails: unknown = event.details;
391
314
  let resultChanged = false;
315
+ let editRecovery: EditRecoveryContext | undefined;
316
+ const resultTarget = dependencies.extractLikelyPath(ctx.cwd, isPlainRecord(event.input) ? event.input : {});
392
317
  if (event.toolName === "grep") {
393
318
  const filtered = filterGrepProtectedContent(resultContent, readProtectedPaths);
394
319
  if (filtered.changed) {
@@ -412,6 +337,44 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
412
337
  }
413
338
  }
414
339
 
340
+ const recovery = buildEditRecoveryContext({
341
+ cwd: ctx.cwd,
342
+ targetPath: resultTarget,
343
+ reasonCode: failureReasonCode,
344
+ protectedPaths: readProtectedPaths
345
+ });
346
+ if (recovery) {
347
+ if (editRecoveryDelivery.reserve(ctx, taskIdentity?.taskRunId ?? "", recovery.key)) {
348
+ editRecovery = recovery;
349
+ const injectedChars = recovery.text.length;
350
+ const injectedEstimatedTokens = estimateContextTokens(recovery.text);
351
+ const recoveryDetails = {
352
+ schemaVersion: 1,
353
+ targetPath: dependencies.redactText(recovery.targetPath),
354
+ contentHash: recovery.contentHash,
355
+ originalChars: recovery.originalChars,
356
+ injectedChars,
357
+ injectedEstimatedTokens,
358
+ sensitiveContentRedacted: recovery.redacted
359
+ };
360
+ resultDetails = isPlainRecord(resultDetails)
361
+ ? { ...resultDetails, piagentEditRecovery: recoveryDetails }
362
+ : { ...(resultDetails === undefined ? {} : { value: resultDetails }), piagentEditRecovery: recoveryDetails };
363
+ resultChanged = true;
364
+ dependencies.telemetry(ctx, {
365
+ event: "edit_recovery_context",
366
+ toolCallId: resultToolCallId,
367
+ taskRunId: taskIdentity?.taskRunId,
368
+ targetPath: dependencies.redactText(recovery.targetPath),
369
+ contentHash: recovery.contentHash,
370
+ originalChars: recovery.originalChars,
371
+ injectedChars,
372
+ injectedEstimatedTokens,
373
+ sensitiveContentRedacted: recovery.redacted
374
+ });
375
+ }
376
+ }
377
+
415
378
  const safeContent = redactToolResultTextContent(resultContent);
416
379
  const safeDetails = redactForStorage(resultDetails);
417
380
  const sensitiveValuesRedacted = safeContent.redacted + countChangedStringLeaves(resultDetails, safeDetails);
@@ -463,11 +426,22 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
463
426
  if (compactionCaptures.length > 0) {
464
427
  resultDetails = attachToolResultCompactionDetails(resultDetails, compactionCaptures);
465
428
  }
429
+ // Compact the original error independently, then append the already bounded
430
+ // and sanitized recovery snapshot. Otherwise a small but line-dense file can
431
+ // be compacted into a preview and cease to be a complete one-shot recovery.
432
+ if (editRecovery) {
433
+ resultContent = appendToolResultText(resultContent, editRecovery.text);
434
+ resultChanged = true;
435
+ }
466
436
 
467
437
  const resultFingerprintId = event.toolCallId ?? fingerprint.key;
468
438
  const record = dependencies.activity ?? dependencies.telemetry;
469
- const resultTarget = dependencies.extractLikelyPath(ctx.cwd, isPlainRecord(event.input) ? event.input : {});
439
+ const changedPaths = [...new Set([...directMutationResult.changedPaths, ...shellChangedPaths])]
440
+ .filter((filePath) => !matchesProtectedPath(filePath, readProtectedPaths))
441
+ .map((filePath) => dependencies.redactText(filePath))
442
+ .sort();
470
443
  const lineStats = patchLineStats(event.details);
444
+ const editRecoveryFailure = ["edit-anchor-not-unique", "edit-anchor-stale"].includes(String(failureReasonCode));
471
445
  record(ctx, {
472
446
  activityId: `result:${resultFingerprintId}`,
473
447
  event: "tool_result",
@@ -475,6 +449,7 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
475
449
  toolCallId: resultFingerprintId,
476
450
  toolName: event.toolName,
477
451
  targetPath: resultTarget ? dependencies.redactText(resultTarget) : undefined,
452
+ changedPaths: changedPaths.length > 0 ? changedPaths : undefined,
478
453
  inputHash: fingerprint.inputHash,
479
454
  outputHash: fingerprint.outputHash,
480
455
  outputChars: fingerprint.outputChars,
@@ -484,6 +459,13 @@ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResul
484
459
  compactedCaptures: compactionCaptures.length,
485
460
  sensitiveValuesRedacted,
486
461
  isError: event.isError,
462
+ reasonCode: failureReasonCode,
463
+ // An explicit false is evidence too: it proves that every classified
464
+ // edit-anchor failure passed through the recovery policy even when the
465
+ // target was too large, private, protected, or otherwise ineligible.
466
+ editRecoveryContext: editRecoveryFailure ? Boolean(editRecovery) : undefined,
467
+ editRecoveryInjectedChars: editRecovery?.text.length,
468
+ editRecoveryEstimatedTokens: editRecovery ? estimateContextTokens(editRecovery.text) : undefined,
487
469
  exitCode: observed?.exitCode ?? (dependencies.isShellTool(event.toolName) ? event.isError ? 1 : 0 : undefined),
488
470
  exitCodeExact: observed?.exitCode !== undefined,
489
471
  ...lineStats,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { redactSensitiveText } from "../../extensions/redaction-core.js";
4
4
  import type { ActivityInspectorEvent } from "../product/activity-inspector.ts";
5
+ import { handledToolFailure } from "./tool-failure-classification.ts";
5
6
  import type { RuntimeEventDraft, RuntimeEventRevision } from "./runtime-event-store.ts";
6
7
 
7
8
  export type RuntimeEventIdentity = {
@@ -52,15 +53,20 @@ export function adaptActivityTelemetryEvent(input: ActivityEventAdapterInput): R
52
53
  const operationRef = hasOperation ? opaque("operation", input.identity.agentOperationId) : null;
53
54
  const toolCallRef = hasOperation ? opaque("tool", rawToolCall) : null;
54
55
  const activityType = hasOperation ? "tool" : ["bash", "shell", "exec"].includes(toolName) ? "command" : "other";
55
- const failed = eventName === "tool_result" && (event.isError === true || typeof event.exitCode === "number" && event.exitCode !== 0);
56
+ const resultFailure = eventName === "tool_result" && (event.isError === true || typeof event.exitCode === "number" && event.exitCode !== 0);
57
+ const failed = resultFailure && !handledToolFailure(event.reasonCode);
56
58
  const blocked = eventName === "tool_decision";
57
- const state = eventName === "tool_call" ? "started" : blocked ? "blocked" : failed ? "failed" : "finished";
59
+ // `tool_call` is emitted before policy authorization. Calling it "started"
60
+ // made the Dashboard claim a command was running while it was still waiting
61
+ // for (or had failed to obtain) permission. Native Gateway
62
+ // `tool_execution_start` remains the authoritative running boundary.
63
+ const state = eventName === "tool_call" ? "requested" : blocked ? "blocked" : failed ? "failed" : "finished";
58
64
  const kind = `activity.${state}`;
59
65
  const previewValue = blocked ? event.reason : event.command ?? event.targetPath ?? event.toolName;
60
66
  const preview = safeText(previewValue, 4000);
61
67
  const storedRedactions = typeof event.sensitiveValuesRedacted === "number" && Number.isInteger(event.sensitiveValuesRedacted)
62
68
  ? Math.max(0, Math.min(10_000, event.sensitiveValuesRedacted)) : 0;
63
- const reasonCode = blocked ? "policy-blocked" : failed ? "tool-result-failed" : null;
69
+ const reasonCode = blocked ? "policy-blocked" : resultFailure ? safeText(event.reasonCode ?? "tool-result-failed", 80).text || "tool-result-failed" : null;
64
70
  return {
65
71
  ...base(input.identity, input.revision, observedAt), agentOperationId: operationRef, toolCallId: toolCallRef,
66
72
  kind, evidence: "derived",
@@ -0,0 +1,104 @@
1
+ import type { ActivityInspectorEvent } from "../product/activity-inspector.ts";
2
+
3
+ const RECOVERY_WINDOW_MS = 15 * 60 * 1_000;
4
+ const SHELL_TOOLS = new Set(["bash", "shell", "exec", "command"]);
5
+ const MUTATION_TOOLS = /(?:^|[._-])(?:apply[_-]?patch|edit|write|create[_-]?file|replace)(?:$|[._-])/i;
6
+
7
+ export type ActivityRecovery = {
8
+ recoveredAt: string;
9
+ recoveryToolCallId: string;
10
+ recoveryToolName: string;
11
+ exitCode: number | null;
12
+ exitCodeExact: boolean;
13
+ };
14
+
15
+ function resultFailed(event: ActivityInspectorEvent | undefined): boolean {
16
+ return Boolean(event && (event.isError === true || typeof event.exitCode === "number" && event.exitCode !== 0));
17
+ }
18
+
19
+ function time(value: unknown): number {
20
+ const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
21
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
22
+ }
23
+
24
+ function normalized(value: unknown): string {
25
+ return typeof value === "string" ? value.replaceAll("\\", "/").replace(/\s+/g, " ").trim() : "";
26
+ }
27
+
28
+ function family(toolName: unknown): "read" | "mutation" | "shell" | "other" {
29
+ const value = String(toolName ?? "").toLowerCase();
30
+ if (SHELL_TOOLS.has(value)) return "shell";
31
+ if (MUTATION_TOOLS.test(value)) return "mutation";
32
+ if (/(?:^|[._-])(?:read|document[_-]?read)(?:$|[._-])/.test(value)) return "read";
33
+ return "other";
34
+ }
35
+
36
+ function likelyCorrectedReadTarget(failed: ActivityInspectorEvent, candidate: ActivityInspectorEvent): boolean {
37
+ if (family(failed.toolName) !== "read" || family(candidate.toolName) !== "read") return false;
38
+ const failedPath = normalized(failed.targetPath).toLowerCase();
39
+ const candidatePath = normalized(candidate.targetPath).toLowerCase();
40
+ const failedSlash = failedPath.lastIndexOf("/"), candidateSlash = candidatePath.lastIndexOf("/");
41
+ if (!failedPath || !candidatePath || failedPath.slice(0, failedSlash) !== candidatePath.slice(0, candidateSlash)) return false;
42
+ const failedName = failedPath.slice(failedSlash + 1), candidateName = candidatePath.slice(candidateSlash + 1);
43
+ const failedExtension = failedName.includes(".") ? failedName.slice(failedName.lastIndexOf(".")) : "";
44
+ const candidateExtension = candidateName.includes(".") ? candidateName.slice(candidateName.lastIndexOf(".")) : "";
45
+ if (failedExtension !== candidateExtension) return false;
46
+ const tokens = (value: string) => new Set(value.slice(0, value.length - failedExtension.length).split(/[^a-z0-9]+/).filter(Boolean));
47
+ const failedTokens = tokens(failedName), candidateTokens = tokens(candidateName);
48
+ const overlap = [...failedTokens].filter((token) => candidateTokens.has(token)).length;
49
+ return overlap >= 2 && overlap === Math.min(failedTokens.size, candidateTokens.size);
50
+ }
51
+
52
+ function sameLogicalTarget(failed: ActivityInspectorEvent, candidate: ActivityInspectorEvent): boolean {
53
+ const failedPath = normalized(failed.targetPath), candidatePath = normalized(candidate.targetPath);
54
+ if (failedPath && candidatePath && failedPath === candidatePath) {
55
+ const failedFamily = family(failed.toolName), candidateFamily = family(candidate.toolName);
56
+ return failedFamily === candidateFamily || failedFamily === "read" && candidateFamily === "mutation";
57
+ }
58
+ const failedCommand = normalized(failed.command), candidateCommand = normalized(candidate.command);
59
+ return Boolean(failedCommand && candidateCommand && failedCommand === candidateCommand
60
+ && family(failed.toolName) === "shell" && family(candidate.toolName) === "shell");
61
+ }
62
+
63
+ export function recoveredToolCalls(events: readonly ActivityInspectorEvent[]): ReadonlyMap<string, ActivityRecovery> {
64
+ const results = new Map<string, { event: ActivityInspectorEvent; order: number }>();
65
+ for (let order = 0; order < events.length; order += 1) {
66
+ const event = events[order];
67
+ if (event?.event === "tool_result" && event.toolCallId) results.set(event.toolCallId, { event, order });
68
+ }
69
+ const segments = new Map<number, number>();
70
+ let segment = 0;
71
+ for (let order = 0; order < events.length; order += 1) {
72
+ if (events[order]?.event === "user_input") segment += 1;
73
+ segments.set(order, segment);
74
+ }
75
+ const calls = events.map((event, order) => ({ event, order }))
76
+ .filter((entry) => entry.event?.event === "tool_call" && Boolean(entry.event.toolCallId));
77
+ const recovered = new Map<string, ActivityRecovery>();
78
+ for (let failedCallIndex = 0; failedCallIndex < calls.length; failedCallIndex += 1) {
79
+ const failedCall = calls[failedCallIndex];
80
+ const failedResult = results.get(failedCall.event.toolCallId!);
81
+ if (!failedResult || !resultFailed(failedResult.event)) continue;
82
+ const failedAt = time(failedResult.event.recordedAt ?? failedCall.event.recordedAt);
83
+ const match = calls.find((candidate, candidateIndex) => {
84
+ if (candidate.order <= failedResult.order || segments.get(candidate.order) !== segments.get(failedCall.order)) return false;
85
+ const candidateResult = results.get(candidate.event.toolCallId!);
86
+ if (!candidateResult || resultFailed(candidateResult.event)) return false;
87
+ const recoveredAt = time(candidateResult.event.recordedAt ?? candidate.event.recordedAt);
88
+ return Number.isFinite(failedAt) && Number.isFinite(recoveredAt) && recoveredAt >= failedAt
89
+ && recoveredAt - failedAt <= RECOVERY_WINDOW_MS
90
+ && (sameLogicalTarget(failedCall.event, candidate.event)
91
+ || candidateIndex === failedCallIndex + 1 && likelyCorrectedReadTarget(failedCall.event, candidate.event));
92
+ });
93
+ if (!match) continue;
94
+ const recoveryResult = results.get(match.event.toolCallId!)!.event;
95
+ recovered.set(failedCall.event.toolCallId!, {
96
+ recoveredAt: String(recoveryResult.recordedAt ?? match.event.recordedAt),
97
+ recoveryToolCallId: match.event.toolCallId!,
98
+ recoveryToolName: String(match.event.toolName ?? "unknown"),
99
+ exitCode: typeof recoveryResult.exitCode === "number" ? recoveryResult.exitCode : null,
100
+ exitCodeExact: recoveryResult.exitCodeExact === true
101
+ });
102
+ }
103
+ return recovered;
104
+ }
@@ -42,6 +42,7 @@ type Pending = { request: Request; resolve(value: ApprovalGuardDecision): void;
42
42
  export type ApprovalBrokerEvent = { kind: "requested" | "resolved" | "expired" | "cancelled"; approvalRef: string; request?: Request; receipt?: Receipt };
43
43
  export type ApprovalGuardDecision = { allowed: boolean; brokered: boolean; receipt: Receipt | null;
44
44
  consume(): boolean; cancel(reasonCode?: string): void };
45
+ export type ApprovalUnavailableFallback = "terminal-confirm" | "deny";
45
46
 
46
47
  function canonical(value: unknown): string {
47
48
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
@@ -60,6 +61,10 @@ function exactKeys(value: unknown, keys: string[]): boolean {
60
61
  function ref(prefix: string, secret: Buffer, value: unknown): string {
61
62
  return `${prefix}.${createHmac("sha256", secret).update(canonical(value)).digest("hex")}`;
62
63
  }
64
+ function providerRef(prefix: string, secret: Buffer, value: string): string | null {
65
+ if (!value) return null;
66
+ return REF.test(value) ? value : ref(prefix, secret, value);
67
+ }
63
68
  function digest(secret: Buffer, value: unknown): string { return `sha256:${createHmac("sha256", secret).update(canonical(value)).digest("hex")}`; }
64
69
  function safe(value: unknown, maximum: number): { text: string; redacted: boolean; truncated: boolean } {
65
70
  const raw = String(value ?? ""); const redacted = redactSensitiveText(raw);
@@ -104,6 +109,13 @@ export class PiApprovalBroker {
104
109
  return targets.length;
105
110
  }
106
111
 
112
+ cancelForOperation(cwd: string, rawSessionId: string, agentOperationId: string, reason = "operation-aborted"): number {
113
+ const binding = this.#bindings.get(key(cwd, rawSessionId)); if (!binding) return 0;
114
+ const targets = [...binding.pending.entries()].filter(([, pending]) => pending.request.identity.agentOperationId === agentOperationId);
115
+ for (const [approvalRef] of targets) this.#cancelPending(binding, approvalRef, reason, "runtime-control", "cancelled");
116
+ return targets.length;
117
+ }
118
+
107
119
  projection(cwd: string, rawSessionId: string): { revision: string | null; summary: Record<string, unknown> } {
108
120
  const binding = this.#bindings.get(key(cwd, rawSessionId));
109
121
  if (!binding) return { revision: null, summary: { state: "unknown", pending: [], recent: [],
@@ -123,22 +135,29 @@ export class PiApprovalBroker {
123
135
  }
124
136
 
125
137
  async request(input: { cwd: string; rawSessionId: string; toolCallId: string; action: ApprovalActionDraft;
126
- expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>; recheck?(): boolean; ttlMs?: number }): Promise<ApprovalGuardDecision> {
138
+ expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>;
139
+ unavailableFallback?: ApprovalUnavailableFallback; recheck?(): boolean; ttlMs?: number }): Promise<ApprovalGuardDecision> {
127
140
  const binding = this.#bindings.get(key(input.cwd, input.rawSessionId));
128
141
  const authority = binding?.authority();
142
+ // Provider tool-call IDs are opaque. OpenAI Responses IDs, for example,
143
+ // can contain `|`, while public WebUI refs deliberately cannot. Rejecting
144
+ // the provider alphabet sent Gateway approvals into the terminal fallback,
145
+ // where no terminal exists. Keep the provider value private and bind the
146
+ // approval to a deterministic broker-local ref instead.
147
+ const toolCallId = binding ? providerRef("tool", binding.secret, input.toolCallId) : null;
129
148
  if (!binding || !authority || authority.taskState !== "active" || input.expectedTask && (authority.identity.taskId !== input.expectedTask.taskId || authority.identity.taskRunId !== input.expectedTask.taskRunId)
130
149
  || !REF.test(authority.identity.agentOperationId)
131
- || !REF.test(input.toolCallId) || binding.pending.size >= MAX_PENDING) return this.#terminalOnly(input.terminalConfirm);
150
+ || !toolCallId || binding.pending.size >= MAX_PENDING) return this.#unavailable(input);
132
151
  const now = Date.now(), requestedAt = new Date(now).toISOString(), expiresAt = new Date(now + Math.min(Math.max(input.ttlMs ?? DEFAULT_TTL_MS, 5_000), DEFAULT_TTL_MS)).toISOString();
133
- const approvalRef = ref("approval", binding.secret, [input.toolCallId, now, randomUUID()]);
152
+ const approvalRef = ref("approval", binding.secret, [toolCallId, now, randomUUID()]);
134
153
  const decisionToken = randomBytes(32).toString("base64url");
135
154
  const nextRevision = this.#nextRevision(binding, "requested", approvalRef);
136
- const identity: Identity = { ...authority.identity, toolCallId: input.toolCallId };
155
+ const identity: Identity = { ...authority.identity, toolCallId };
137
156
  const action = this.#action(binding, input.action, input.cwd);
138
157
  const expectedRevisions: Revisions = { ...authority.revisions, approvalRevision: nextRevision,
139
158
  treePrecondition: input.action.preconditionClass === "runtime-only" ? null : input.action.treePrecondition ?? null };
140
159
  if ((input.action.preconditionClass !== "runtime-only" && !expectedRevisions.treePrecondition)
141
- || !this.#validTree(expectedRevisions.treePrecondition)) return this.#terminalOnly(input.terminalConfirm);
160
+ || !this.#validTree(expectedRevisions.treePrecondition)) return this.#unavailable(input);
142
161
  const request: Request = { schemaVersion: 1, version: "piagent-webui-approval-v1", recordType: "request", approvalRef, decisionToken,
143
162
  identity, action, expectedRevisions, state: "waiting", requestedAt, expiresAt, executor: "pi-guard", directExecution: false };
144
163
  const result = new Promise<ApprovalGuardDecision>((resolve) => {
@@ -183,6 +202,11 @@ export class PiApprovalBroker {
183
202
  consume: () => false, cancel: () => undefined }));
184
203
  }
185
204
 
205
+ #unavailable(input: { terminalConfirm(): Promise<boolean>; unavailableFallback?: ApprovalUnavailableFallback }): Promise<ApprovalGuardDecision> {
206
+ if (input.unavailableFallback !== "deny") return this.#terminalOnly(input.terminalConfirm);
207
+ return Promise.resolve({ allowed: false, brokered: false, receipt: null, consume: () => false, cancel: () => undefined });
208
+ }
209
+
186
210
  #action(binding: Binding, draft: ApprovalActionDraft, cwd: string): Record<string, unknown> {
187
211
  const command = draft.commandPreview === null || draft.commandPreview === undefined ? null : safe(draft.commandPreview, 4_000);
188
212
  const parameter = safe(draft.parameterPreview ?? "No additional parameters", 4_000);
@@ -96,7 +96,7 @@ export function projectTaskCompactionHistory(input: {
96
96
  let malformed = inspected.corruptions;
97
97
  const matching = inspected.records.flatMap((record: any, ordinal: number) => {
98
98
  if (record?.taskId !== input.task.taskId || record?.taskRunId !== input.task.taskRunId || record?.sessionId !== input.task.sessionId) return [];
99
- if (record.schemaVersion !== 1 || !timestamp(record.recordedAt)) { malformed += 1; return []; }
99
+ if (![1, 2].includes(record.schemaVersion) || !timestamp(record.recordedAt)) { malformed += 1; return []; }
100
100
  if (record.event === "session_compact") {
101
101
  if (typeof record.willRetry !== "boolean" || typeof record.fromExtension !== "boolean") { malformed += 1; return []; }
102
102
  return [{ ordinal, recordedAt: record.recordedAt, kind: "context-compaction", title: "Context đã được compact",
@@ -60,7 +60,7 @@ export function projectTaskHandoffHistory(input: { cwd: string; task: Task; iden
60
60
  const matching = inspected.records.flatMap((record: any, ordinal: number) => {
61
61
  if (record?.event !== "handoff_projection_written" || record.taskId !== input.task.taskId
62
62
  || record.taskRunId !== input.task.taskRunId || record.sessionId !== input.task.sessionId) return [];
63
- if (record.schemaVersion !== 1 || !timestamp(record.recordedAt) || typeof record.completionApproved !== "boolean") { malformed += 1; return []; }
63
+ if (![1, 2].includes(record.schemaVersion) || !timestamp(record.recordedAt) || typeof record.completionApproved !== "boolean") { malformed += 1; return []; }
64
64
  const action = display(record.recoveryAction, 80) || "unknown", phase = display(record.phase, 64) || null;
65
65
  return [{ ordinal, recordedAt: record.recordedAt, phase, completionApproved: record.completionApproved, projectedAction: action }];
66
66
  });