@piagent/platform 1.5.5 → 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 (227) hide show
  1. package/CHANGELOG.md +117 -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 +5 -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 +2 -0
  136. package/packages/piagent-core/extensions/javascript-regex-evidence.js +32 -0
  137. package/packages/piagent-core/extensions/piagent-guard.ts +233 -41
  138. package/packages/piagent-core/extensions/task-contract-view.js +17 -3
  139. package/packages/piagent-core/extensions/task-lifecycle.js +7 -2
  140. package/packages/piagent-core/extensions/task-state.js +40 -134
  141. package/packages/piagent-core/extensions/verification-intelligence.js +3 -1
  142. package/packages/piagent-core/extensions/workspace-evidence-roots.js +157 -0
  143. package/packages/piagent-core/package.json +1 -1
  144. package/packages/piagent-core/policies/base-policy.json +2 -0
  145. package/packages/piagent-core/runtime/context/context-delivery.ts +103 -0
  146. package/packages/piagent-core/runtime/context/context-delta-shadow.ts +53 -0
  147. package/packages/piagent-core/runtime/context/context-efficiency-format.ts +47 -0
  148. package/packages/piagent-core/runtime/context/context-evidence-qualification.ts +32 -0
  149. package/packages/piagent-core/runtime/context/prefix-telemetry.ts +47 -0
  150. package/packages/piagent-core/runtime/context/repository-memory-hints.ts +27 -0
  151. package/packages/piagent-core/runtime/hooks/agent-prompt-telemetry.ts +56 -0
  152. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +162 -102
  153. package/packages/piagent-core/runtime/hooks/completion-hook.ts +5 -6
  154. package/packages/piagent-core/runtime/hooks/input-hook.ts +11 -1
  155. package/packages/piagent-core/runtime/hooks/session-hooks.ts +22 -2
  156. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +7 -2
  157. package/packages/piagent-core/runtime/hooks/tool-result-content-guards.ts +95 -0
  158. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +84 -102
  159. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +4 -2
  160. package/packages/piagent-core/runtime/inspection/activity-recovery.ts +104 -0
  161. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +1 -1
  162. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +1 -1
  163. package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +53 -0
  164. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +34 -6
  165. package/packages/piagent-core/runtime/model/authenticated-catalog.ts +17 -9
  166. package/packages/piagent-core/runtime/model/openai-codex-reasoning.ts +83 -0
  167. package/packages/piagent-core/runtime/model/provider-wire-fingerprint.ts +238 -0
  168. package/packages/piagent-core/runtime/orchestration/role-policy.ts +1 -1
  169. package/packages/piagent-core/runtime/product/activity-inspector.ts +7 -2
  170. package/packages/piagent-core/runtime/product/efficiency-metrics.ts +4 -2
  171. package/packages/piagent-core/runtime/quality/exact-output-contract.ts +2 -1
  172. package/packages/piagent-core/runtime/quality/performance-assurance-signals.ts +23 -0
  173. package/packages/piagent-core/runtime/quality/performance-assurance.ts +6 -2
  174. package/packages/piagent-core/runtime/recovery/edit-recovery-context.ts +113 -0
  175. package/packages/piagent-core/runtime/recovery/edit-recovery-delivery.ts +42 -0
  176. package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +55 -2
  177. package/packages/piagent-core/runtime/recovery/semantic-repair-runtime.ts +55 -2
  178. package/packages/piagent-core/runtime/registration/context-commands.ts +66 -5
  179. package/packages/piagent-core/runtime/registration/policy-tools.ts +46 -11
  180. package/packages/piagent-core/runtime/registration/task-completion-tools.ts +49 -16
  181. package/packages/piagent-core/runtime/registration/task-start-guidance.ts +163 -0
  182. package/packages/piagent-core/runtime/registration/task-start-retry-helpers.ts +37 -0
  183. package/packages/piagent-core/runtime/registration/task-start-tool.ts +67 -62
  184. package/packages/piagent-core/runtime/session/runtime-state.ts +154 -2
  185. package/packages/piagent-core/runtime/session/system-prompt.ts +4 -4
  186. package/packages/piagent-core/runtime/tools/apply-patch-tool.ts +500 -0
  187. package/packages/piagent-core/runtime/trajectory/trajectory-runtime.ts +2 -1
  188. package/packages/piagent-core/runtime/workflows/task-intake.ts +43 -2
  189. package/packages/piagent-core/security/sensitive-data.js +13 -218
  190. package/packages/piagent-core/security/sensitive-project-file.js +42 -0
  191. package/packages/piagent-core/security/sensitive-source-expression.js +330 -0
  192. package/packages/piagent-core/security/sensitive-source-formats.js +394 -0
  193. package/packages/piagent-core/security/sensitive-text.js +234 -0
  194. package/packages/piagent-core/subagents/piagent-worker.md +1 -1
  195. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-rD9WJKxs.js → MarkdownMessage-CxeZqTte.js} +1 -1
  196. package/packages/piagent-webui/dist/client/assets/index-DynIMe8I.js +176 -0
  197. package/packages/piagent-webui/dist/client/index.html +1 -1
  198. package/packages/piagent-webui/gateway/gateway-events.ts +109 -5
  199. package/packages/piagent-webui/gateway/gateway-service.ts +3 -0
  200. package/packages/piagent-webui/gateway/gateway-session-stream.ts +93 -7
  201. package/packages/piagent-webui/gateway/session-command-controller.ts +18 -3
  202. package/packages/piagent-webui/gateway/session-effective-options.ts +47 -0
  203. package/packages/piagent-webui/gateway/session-live-state.ts +21 -0
  204. package/packages/piagent-webui/gateway/session-operation-start.ts +5 -1
  205. package/packages/piagent-webui/gateway/session-operation-watchdog.ts +158 -0
  206. package/packages/piagent-webui/gateway/session-runtime-factory.ts +45 -0
  207. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +139 -124
  208. package/packages/piagent-webui/server/loopback-server.ts +8 -0
  209. package/packages/piagent-webui/server/transcript-projection.ts +22 -4
  210. package/packages/piagent-webui/shared/text-visibility.ts +10 -0
  211. package/schemas/piagent-webui/catalog-v1.json +9 -0
  212. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +53 -3
  213. package/schemas/piagent-webui/session-live-state-v1.schema.json +76 -0
  214. package/schemas/task-contract.schema.json +12 -1
  215. package/scripts/benchmark-runner-core.mjs +355 -359
  216. package/scripts/benchmark-runner-finalization.mjs +309 -0
  217. package/scripts/benchmark-runner-host-readiness.mjs +94 -0
  218. package/scripts/benchmark-runner-support.mjs +200 -0
  219. package/scripts/benchmark-session.mjs +95 -41
  220. package/scripts/configure-model-scope.sh +4 -3
  221. package/scripts/helper-evaluation.mjs +1 -1
  222. package/scripts/install-global.sh +2 -2
  223. package/scripts/pi-usage-history.mjs +37 -10
  224. package/scripts/setup.sh +2 -2
  225. package/scripts/verify-local.sh +5 -1
  226. package/templates/global/settings.json +2 -1
  227. package/packages/piagent-webui/dist/client/assets/index-BZRFw1u6.js +0 -174
@@ -7,7 +7,7 @@
7
7
  <meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
8
8
  <meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
9
9
  <title>Piagent WebUI</title>
10
- <script type="module" crossorigin src="/assets/index-BZRFw1u6.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DynIMe8I.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-BV-_03yl.css">
12
12
  </head>
13
13
  <body>
@@ -1,5 +1,7 @@
1
+ import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
2
+
1
3
  export type GatewayEventKind = "catalog.changed" | "session.changed" | "runtime.changed" | "message.delta"
2
- | "message.completed" | "tool.started" | "tool.completed";
4
+ | "message.completed" | "operation.settled" | "tool.started" | "tool.completed";
3
5
 
4
6
  export type GatewayProtocolEvent = {
5
7
  schemaVersion: 1;
@@ -12,16 +14,47 @@ export type GatewayProtocolEvent = {
12
14
  payload: Record<string, unknown>;
13
15
  };
14
16
 
17
+ export type GatewayTerminalSettlement = {
18
+ sessionRef: string;
19
+ operationRef: string;
20
+ settlement: "blocked" | "aborted" | "error" | "unknown";
21
+ reasonCode: string;
22
+ settledAt: string;
23
+ sequence: number;
24
+ };
25
+
26
+ const OPAQUE_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
27
+ const REASON_CODE_PATTERN = /^[a-z0-9][a-z0-9.-]{0,95}$/;
28
+ const TERMINAL_SETTLEMENTS = new Set(["completed", "blocked", "aborted", "error", "unknown"]);
29
+ const SETTLEMENT_RETENTION_COUNT = 100;
30
+ const SETTLEMENT_RETENTION_MS = 60 * 60_000;
31
+ const MAX_SETTLEMENT_DECISION_COUNT = 10_000;
32
+ type GatewaySettlementDecision = { settledAt: string; settlement: "completed" | GatewayTerminalSettlement["settlement"];
33
+ reasonCode: string | null };
34
+
35
+ function settlementReason(settlement: GatewayTerminalSettlement["settlement"], value: unknown): string {
36
+ if (typeof value === "string" && REASON_CODE_PATTERN.test(value) && !redactSensitiveText(value).redacted) return value;
37
+ if (settlement === "aborted") return "operation-aborted";
38
+ if (settlement === "blocked") return "operation-blocked";
39
+ if (settlement === "error") return "operation-failed";
40
+ return "operation-settlement-unknown";
41
+ }
42
+
15
43
  export class GatewayEventStore {
16
44
  readonly #maximumCount: number;
17
45
  readonly #maximumAgeMs: number;
46
+ readonly #settlementDecisionCount: number;
18
47
  #events: GatewayProtocolEvent[] = [];
48
+ #settlements: GatewayTerminalSettlement[] = [];
49
+ #settlementDecisions = new Map<string, GatewaySettlementDecision>();
19
50
  #sequence = 0;
20
51
  #listeners = new Set<(event: GatewayProtocolEvent) => void>();
21
52
 
22
- constructor(options: { maximumCount?: number; maximumAgeMs?: number } = {}) {
53
+ constructor(options: { maximumCount?: number; maximumAgeMs?: number; settlementDecisionCount?: number } = {}) {
23
54
  this.#maximumCount = Math.max(1, Math.min(10_000, options.maximumCount ?? 1_000));
24
55
  this.#maximumAgeMs = Math.max(1_000, Math.min(24 * 60 * 60_000, options.maximumAgeMs ?? 60 * 60_000));
56
+ this.#settlementDecisionCount = Math.max(1,
57
+ Math.min(MAX_SETTLEMENT_DECISION_COUNT, Math.floor(options.settlementDecisionCount ?? MAX_SETTLEMENT_DECISION_COUNT)));
25
58
  }
26
59
 
27
60
  get stateVersion(): number { return this.#sequence; }
@@ -31,14 +64,74 @@ export class GatewayEventStore {
31
64
  while (this.#events.length > this.#maximumCount || this.#events[0] && Date.parse(this.#events[0].generatedAt) < cutoff) this.#events.shift();
32
65
  }
33
66
 
67
+ #pruneSettlements(now: number): void {
68
+ const cutoff = now - SETTLEMENT_RETENTION_MS;
69
+ this.#settlements = this.#settlements.filter((item) => Date.parse(item.settledAt) >= cutoff);
70
+ if (this.#settlements.length > SETTLEMENT_RETENTION_COUNT) {
71
+ this.#settlements.splice(0, this.#settlements.length - SETTLEMENT_RETENTION_COUNT);
72
+ }
73
+ for (const [key, decision] of this.#settlementDecisions) {
74
+ if (Date.parse(decision.settledAt) < cutoff) this.#settlementDecisions.delete(key);
75
+ }
76
+ while (this.#settlementDecisions.size > this.#settlementDecisionCount) {
77
+ this.#settlementDecisions.delete(this.#settlementDecisions.keys().next().value as string);
78
+ }
79
+ }
80
+
81
+ #settlementDecision(sessionRef: string, operationRef: string): GatewaySettlementDecision | undefined {
82
+ const key = `${sessionRef}\u0000${operationRef}`;
83
+ const indexed = this.#settlementDecisions.get(key);
84
+ if (indexed) return indexed;
85
+ const retained = this.#settlements.find((item) => item.sessionRef === sessionRef && item.operationRef === operationRef);
86
+ if (!retained) return undefined;
87
+ const restored = { settledAt: retained.settledAt, settlement: retained.settlement, reasonCode: retained.reasonCode };
88
+ this.#settlementDecisions.set(key, restored);
89
+ return restored;
90
+ }
91
+
92
+ #recordSettlement(event: GatewayProtocolEvent, now: number): void {
93
+ if (event.kind !== "operation.settled") return;
94
+ const sessionRef = event.payload.sessionRef, operationRef = event.payload.operationRef, settlement = event.payload.settlement;
95
+ if (typeof sessionRef !== "string" || !OPAQUE_REF_PATTERN.test(sessionRef)
96
+ || typeof operationRef !== "string" || !OPAQUE_REF_PATTERN.test(operationRef)
97
+ || typeof settlement !== "string" || !TERMINAL_SETTLEMENTS.has(settlement)) return;
98
+ this.#pruneSettlements(now);
99
+ const key = `${sessionRef}\u0000${operationRef}`;
100
+ if (this.#settlementDecision(sessionRef, operationRef)) return;
101
+ this.#settlementDecisions.set(key, { settledAt: event.generatedAt,
102
+ settlement: settlement as GatewaySettlementDecision["settlement"],
103
+ reasonCode: settlement === "completed" ? null : String(event.payload.reasonCode) });
104
+ const retained = { sessionRef, operationRef, settledAt: event.generatedAt, sequence: event.sequence };
105
+ if (settlement !== "completed") {
106
+ const outcome = settlement as GatewayTerminalSettlement["settlement"];
107
+ this.#settlements.push({ ...retained, settlement: outcome, reasonCode: settlementReason(outcome, event.payload.reasonCode) });
108
+ }
109
+ this.#pruneSettlements(now);
110
+ }
111
+
34
112
  publish(kind: GatewayEventKind, payload: Record<string, unknown>, now = new Date()): GatewayProtocolEvent {
35
113
  if (!Number.isFinite(now.getTime())) throw new Error("gateway-event-time-invalid");
114
+ if (kind === "operation.settled") {
115
+ if (payload.settlement === "completed") payload = { ...payload, reasonCode: null };
116
+ else {
117
+ const settlement = ["blocked", "aborted", "error", "unknown"].includes(String(payload.settlement))
118
+ ? payload.settlement as GatewayTerminalSettlement["settlement"] : "unknown";
119
+ payload = { ...payload, settlement, reasonCode: settlementReason(settlement, payload.reasonCode) };
120
+ }
121
+ this.#pruneSettlements(now.getTime());
122
+ const sessionRef = payload.sessionRef, operationRef = payload.operationRef;
123
+ if (typeof sessionRef === "string" && OPAQUE_REF_PATTERN.test(sessionRef)
124
+ && typeof operationRef === "string" && OPAQUE_REF_PATTERN.test(operationRef)) {
125
+ const canonical = this.#settlementDecision(sessionRef, operationRef);
126
+ if (canonical) payload = { ...payload, settlement: canonical.settlement, reasonCode: canonical.reasonCode };
127
+ }
128
+ }
36
129
  this.#sequence += 1;
37
130
  const event: GatewayProtocolEvent = {
38
131
  schemaVersion: 1, version: "piagent-gateway-protocol-v1", messageType: "event",
39
132
  sequence: this.#sequence, stateVersion: this.#sequence, generatedAt: now.toISOString(), kind, payload
40
133
  };
41
- this.#events.push(event); this.#prune(now.getTime());
134
+ this.#events.push(event); this.#prune(now.getTime()); this.#recordSettlement(event, now.getTime());
42
135
  for (const listener of this.#listeners) { try { listener(event); } catch { /* observers cannot block truth */ } }
43
136
  return event;
44
137
  }
@@ -48,10 +141,21 @@ export class GatewayEventStore {
48
141
  return () => { this.#listeners.delete(listener); };
49
142
  }
50
143
 
144
+ recentOperationSettlements(maximum = 100, now = new Date()): GatewayTerminalSettlement[] {
145
+ if (!Number.isFinite(now.getTime())) throw new Error("gateway-settlement-time-invalid");
146
+ const limit = Math.max(0, Math.min(100, Math.floor(maximum)));
147
+ if (limit === 0) return [];
148
+ this.#pruneSettlements(now.getTime());
149
+ return [...this.#settlements].sort((left, right) => right.sequence - left.sequence).slice(0, limit).map((item) => ({ ...item }));
150
+ }
151
+
51
152
  replay(after: number | null): { state: "current" | "resync-required"; events: GatewayProtocolEvent[]; earliestSequence: number; currentSequence: number } {
52
153
  this.#prune(Date.now());
53
- const earliestSequence = this.#events[0]?.sequence ?? this.#sequence;
54
- if (after !== null && after < Math.max(0, earliestSequence - 1)) {
154
+ // An empty retained ring after pruning contains no event at `#sequence`.
155
+ // Treat the next sequence as the retention boundary so a client one event
156
+ // behind must resync instead of silently receiving an empty replay.
157
+ const earliestSequence = this.#events[0]?.sequence ?? Math.min(Number.MAX_SAFE_INTEGER, this.#sequence + 1);
158
+ if (after !== null && (after > this.#sequence || after < Math.max(0, earliestSequence - 1))) {
55
159
  return { state: "resync-required", events: [], earliestSequence, currentSequence: this.#sequence };
56
160
  }
57
161
  return { state: "current", events: after === null ? [] : this.#events.filter((event) => event.sequence > after), earliestSequence,
@@ -21,6 +21,7 @@ import { SessionMetadataStore } from "./session-metadata-store.ts";
21
21
  import { GatewayProtocolService } from "./gateway-protocol-service.ts";
22
22
  import { SessionLeaseStore } from "./session-lease-store.ts";
23
23
  import { SessionRuntimeSupervisor } from "./session-runtime-supervisor.ts";
24
+ import { buildSessionLiveState } from "./session-live-state.ts";
24
25
  import { SessionCommandStore } from "./session-command-store.ts";
25
26
  import { SessionCommandController } from "./session-command-controller.ts";
26
27
  import { RuntimeCommandController } from "./runtime-command-controller.ts";
@@ -192,6 +193,8 @@ export async function startPiagentGateway(options: {
192
193
  mode: "gateway",
193
194
  readCapabilities: () => capabilities(gatewayInstanceRef, true),
194
195
  readSessionCatalog: readCatalog,
196
+ readSessionLiveState: () => buildSessionLiveState({ gatewayInstanceRef, eventSequence: events.stateVersion,
197
+ operations: runtimes!.currentOperations(), settlements: events.recentOperationSettlements() }),
195
198
  readSessionCreationOptions: () => inspections.creationOptions(),
196
199
  readSessionModel: (sessionRef) => inspections.provider(sessionRef),
197
200
  readSessionConnections: (sessionRef) => inspections.connections(sessionRef),
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
 
3
3
  import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
4
+ import { hasVisibleText } from "../shared/text-visibility.ts";
4
5
  import { GatewayEventStore } from "./gateway-events.ts";
5
6
 
6
7
  const MAX_BUFFER = 65_536;
@@ -8,9 +9,14 @@ const MAX_EVENTS = 128;
8
9
  const MAX_DELTA = 16_384;
9
10
  const PRIVATE_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
10
11
  const PRIVATE_END = /-----END [A-Z ]*PRIVATE KEY-----/;
12
+ const RUNTIME_DRIFT = /Installed Piagent runtime changed (?:during this session|while the capability lock was being re-pinned)/i;
13
+ const COMPLETION_GATE = /^\[Piagent completion gate: (CONTINUING|NOT APPROVED)\]/i;
14
+
15
+ export type GatewayOperationSettlement = "completed" | "blocked" | "aborted" | "error" | "unknown";
16
+ type Settlement = { outcome: GatewayOperationSettlement; reasonCode: string | null };
11
17
 
12
18
  function clean(value: unknown): string {
13
- return String(value ?? "").replace(/\u001b(?:[@-_]|\[[0-?]*[ -/]*[@-~])/g, "")
19
+ return String(value ?? "").replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g, "")
14
20
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
15
21
  }
16
22
  function safeTool(value: unknown): string {
@@ -19,6 +25,44 @@ function safeTool(value: unknown): string {
19
25
  return safe.text.replace(/[^A-Za-z0-9._:-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 160) || "tool";
20
26
  }
21
27
  function ref(prefix: string): string { return `${prefix}_${randomBytes(18).toString("base64url")}`; }
28
+ function messageText(message: any): string {
29
+ if (typeof message?.content === "string") return message.content;
30
+ if (!Array.isArray(message?.content)) return "";
31
+ return message.content.filter((item: any) => item?.type === "text" && typeof item.text === "string")
32
+ .map((item: any) => item.text).join("\n");
33
+ }
34
+ function assistantSettlement(message: any): Settlement {
35
+ const stopReason = String(message?.stopReason ?? "");
36
+ if (stopReason === "aborted") return { outcome: "aborted", reasonCode: "operation-aborted" };
37
+ if (stopReason === "error") return { outcome: "error", reasonCode: "assistant-response-failed" };
38
+ if (stopReason === "length") return { outcome: "error", reasonCode: "assistant-output-incomplete" };
39
+ const gate = COMPLETION_GATE.exec(messageText(message).trimStart());
40
+ if (gate) return { outcome: "blocked", reasonCode: gate[1]?.toUpperCase() === "CONTINUING"
41
+ ? "completion-gate-continuing" : "completion-gate-not-approved" };
42
+ if (stopReason === "stop") return hasVisibleText(redactSensitiveText(clean(messageText(message))).text)
43
+ ? { outcome: "completed", reasonCode: null }
44
+ : { outcome: "unknown", reasonCode: "assistant-message-empty" };
45
+ if (["toolUse", "pending", "deferred"].includes(stopReason)) return { outcome: "unknown", reasonCode: "assistant-message-intermediate" };
46
+ return { outcome: "unknown", reasonCode: "operation-settlement-unknown" };
47
+ }
48
+
49
+ function containsRuntimeDrift(value: unknown, depth = 0, remaining = { value: 16_384 }): boolean {
50
+ if (depth > 5 || remaining.value <= 0 || value === null || value === undefined) return false;
51
+ if (typeof value === "string") {
52
+ const text = value.slice(0, remaining.value); remaining.value -= text.length;
53
+ return RUNTIME_DRIFT.test(text);
54
+ }
55
+ if (Array.isArray(value)) return value.some((item) => containsRuntimeDrift(item, depth + 1, remaining));
56
+ if (typeof value !== "object") return false;
57
+ const record = value as Record<string, unknown>;
58
+ return ["reason", "message", "text", "content", "result", "details"]
59
+ .some((key) => containsRuntimeDrift(record[key], depth + 1, remaining));
60
+ }
61
+
62
+ export function runtimeRestartReasonCode(event: unknown): "runtime-restart-required" | null {
63
+ if (!event || typeof event !== "object" || (event as Record<string, unknown>).isError !== true) return null;
64
+ return containsRuntimeDrift((event as Record<string, unknown>).result) ? "runtime-restart-required" : null;
65
+ }
22
66
 
23
67
  export class GatewaySessionStream {
24
68
  readonly sessionRef: string;
@@ -32,9 +76,28 @@ export class GatewaySessionStream {
32
76
  #started = false;
33
77
  #startListeners: Array<() => void> = [];
34
78
  #settled = false;
79
+ #runtimeRestartRequired = false;
80
+ #lastMessageRef: string | null = null;
81
+ #settlement: Settlement = { outcome: "unknown", reasonCode: "operation-settlement-unknown" };
82
+ #forcedSettlement: Settlement | null = null;
83
+ #lifecycleTerminationReasonCode: string | null = null;
84
+ #terminalEmitted = false;
35
85
  #settleListeners: Array<() => void> = [];
36
86
  readonly #toolRefs = new Map<string, string>();
37
87
 
88
+ get runtimeRestartRequired(): boolean { return this.#runtimeRestartRequired; }
89
+ get lifecycleTerminationReasonCode(): string | null { return this.#lifecycleTerminationReasonCode; }
90
+
91
+ markAborted(reasonCode = "operation-aborted"): void { this.#forcedSettlement = { outcome: "aborted", reasonCode }; }
92
+ markError(reasonCode = "operation-failed"): void {
93
+ if (!this.#forcedSettlement) this.#forcedSettlement = { outcome: "error", reasonCode };
94
+ }
95
+ forceLifecycleTermination(reasonCode: string): void {
96
+ this.#lifecycleTerminationReasonCode ??= reasonCode;
97
+ if (!this.#started) { this.#started = true; for (const resolve of this.#startListeners.splice(0)) resolve(); }
98
+ if (!this.#settled) { this.#settled = true; for (const resolve of this.#settleListeners.splice(0)) resolve(); }
99
+ }
100
+
38
101
  constructor(options: { sessionRef: string; operationRef: string; events: GatewayEventStore }) {
39
102
  this.sessionRef = options.sessionRef; this.operationRef = options.operationRef; this.#events = options.events;
40
103
  }
@@ -50,6 +113,7 @@ export class GatewaySessionStream {
50
113
  }
51
114
 
52
115
  observe(event: any): void {
116
+ if (this.#terminalEmitted) return;
53
117
  if (event?.type === "agent_start") {
54
118
  this.#started = true; for (const resolve of this.#startListeners.splice(0)) resolve(); return;
55
119
  }
@@ -67,17 +131,21 @@ export class GatewaySessionStream {
67
131
  if (event.assistantMessageEvent.delta.length > remaining) this.#truncated = true;
68
132
  this.#flush(false); return;
69
133
  }
70
- if (event?.type === "message_end" && event.message?.role === "assistant") { this.#flush(true); return; }
134
+ if (event?.type === "message_end" && event.message?.role === "assistant") {
135
+ this.#flush(true); this.#lastMessageRef = this.#messageRef; this.#settlement = assistantSettlement(event.message); return;
136
+ }
71
137
  if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") {
72
138
  const toolCallRef = ref("tool"); this.#toolRefs.set(event.toolCallId, toolCallRef);
73
139
  this.#events.publish("tool.started", { sessionRef: this.sessionRef, operationRef: this.operationRef, toolCallRef,
74
- toolLabel: safeTool(event.toolName), isError: null });
140
+ toolLabel: safeTool(event.toolName), isError: null, reasonCode: null });
75
141
  return;
76
142
  }
77
143
  if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") {
78
144
  const toolCallRef = this.#toolRefs.get(event.toolCallId); if (!toolCallRef) return;
145
+ const reasonCode = runtimeRestartReasonCode(event);
146
+ if (reasonCode) this.#runtimeRestartRequired = true;
79
147
  this.#events.publish("tool.completed", { sessionRef: this.sessionRef, operationRef: this.operationRef, toolCallRef,
80
- toolLabel: safeTool(event.toolName), isError: event.isError === true });
148
+ toolLabel: safeTool(event.toolName), isError: event.isError === true, reasonCode });
81
149
  this.#toolRefs.delete(event.toolCallId);
82
150
  }
83
151
  }
@@ -112,9 +180,27 @@ export class GatewaySessionStream {
112
180
  }
113
181
  }
114
182
 
115
- complete(sessionRevision: string): void {
183
+ complete(sessionRevision: string | null): void {
184
+ if (this.#terminalEmitted) return;
185
+ this.#terminalEmitted = true;
116
186
  this.#flush(true);
117
- if (this.#messageRef) this.#events.publish("message.completed", { sessionRef: this.sessionRef, operationRef: this.operationRef,
118
- messageRef: this.#messageRef, sessionRevision, truncated: this.#truncated });
187
+ let settlement = this.#forcedSettlement ?? (this.#runtimeRestartRequired
188
+ ? { outcome: "unknown" as const, reasonCode: "runtime-restart-required" }
189
+ : this.#settlement);
190
+ const messageRef = this.#lastMessageRef ?? this.#messageRef;
191
+ if (settlement.outcome === "completed" && !messageRef) {
192
+ settlement = { outcome: "unknown", reasonCode: "assistant-message-unavailable" };
193
+ } else if (settlement.outcome === "completed" && sessionRevision === null) {
194
+ // Streaming text is not durable success until the canonical session
195
+ // projection confirms it. Still terminate the UI, but do not show draft.
196
+ settlement = { outcome: "unknown", reasonCode: "session-projection-unavailable" };
197
+ }
198
+ if (settlement.outcome === "completed" && messageRef && sessionRevision) {
199
+ this.#events.publish("message.completed", { sessionRef: this.sessionRef, operationRef: this.operationRef,
200
+ messageRef, sessionRevision, truncated: this.#truncated });
201
+ }
202
+ this.#events.publish("operation.settled", { sessionRef: this.sessionRef, operationRef: this.operationRef,
203
+ messageRef, sessionRevision, settlement: settlement.outcome,
204
+ reasonCode: settlement.outcome === "completed" ? null : settlement.reasonCode ?? "operation-settlement-unknown" });
119
205
  }
120
206
  }
@@ -154,8 +154,18 @@ export class SessionCommandController {
154
154
  | "renamed" | "pinned" | "unpinned" | "archived" | "unarchived" | "forked" | "no-change",
155
155
  operationRef: string | null = null;
156
156
  if (command.action === "session.create") {
157
- targetSessionRef = await this.#runtimes.create(String(command.payload.projectRef), String(command.payload.placeRef),
158
- command.payload.modelRef === null ? null : String(command.payload.modelRef), String(command.payload.thinkingLevel));
157
+ const createWithReadback = (this.#runtimes as any).createWithReadback;
158
+ if (typeof createWithReadback === "function") {
159
+ const created = await createWithReadback.call(this.#runtimes, String(command.payload.projectRef), String(command.payload.placeRef),
160
+ command.payload.modelRef === null ? null : String(command.payload.modelRef), String(command.payload.thinkingLevel));
161
+ targetSessionRef = String(created.sessionRef);
162
+ if (created.effectiveOptions?.state !== "confirmed") throw new Error(
163
+ typeof created.effectiveOptions?.reasonCode === "string" ? created.effectiveOptions.reasonCode : "session-create-options-effect-unknown");
164
+ } else {
165
+ // Compatibility with older embedded supervisors and test doubles.
166
+ targetSessionRef = await this.#runtimes.create(String(command.payload.projectRef), String(command.payload.placeRef),
167
+ command.payload.modelRef === null ? null : String(command.payload.modelRef), String(command.payload.thinkingLevel));
168
+ }
159
169
  if (command.payload.permissionMode !== undefined) {
160
170
  await this.#runtimes.setPermission(targetSessionRef, String(command.payload.permissionMode) as
161
171
  "read-only" | "workspace-write" | "trusted-full-access");
@@ -246,9 +256,14 @@ export class SessionCommandController {
246
256
  const code = /recovery/.test(message) ? "recovery-required"
247
257
  : /(owner-conflict|operation-conflict|runtime-busy)/.test(message) ? "owner-conflict"
248
258
  : noDispatchEffect || command.action === "session.send" && !sendAttempted ? "unavailable" : "effect-unknown";
249
- const receipt = code === "effect-unknown" ? this.#uncertain(command, after, afterRow, "session-command-effect-unknown")
259
+ const specificCreateUncertainty = command.action === "session.create" && /^[a-z0-9][a-z0-9.-]{0,95}$/.test(message)
260
+ ? message : "session-command-effect-unknown";
261
+ const receipt = code === "effect-unknown" ? this.#uncertain(command, after, afterRow, specificCreateUncertainty)
250
262
  : this.#rejected(command, after, afterRow, code, message);
251
263
  try { this.#store.settle(command, receipt, this.#now()); } catch { /* intent remains uncertain */ }
264
+ if (command.action === "session.create" && afterRow) {
265
+ this.#events.publish("session.changed", { catalogRevision: after.catalogRevision, session: afterRow });
266
+ }
252
267
  return receipt;
253
268
  }
254
269
  }
@@ -0,0 +1,47 @@
1
+ import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
2
+
3
+ export type EffectiveSessionOptions = {
4
+ state: "confirmed" | "mismatch" | "unknown";
5
+ modelRef: string | null;
6
+ thinkingLevel: string | null;
7
+ reasonCode: string | null;
8
+ };
9
+
10
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
11
+
12
+ export function effectiveModelRef(session: any): string | null {
13
+ const model = session?.model;
14
+ if (!model || typeof model !== "object") return null;
15
+ const provider = String(model.provider ?? ""), id = String(model.id ?? model.modelId ?? "");
16
+ return provider && id ? webUiModelRef(provider, id) : null;
17
+ }
18
+
19
+ export function effectiveThinkingLevel(session: any): string | null {
20
+ let value: unknown;
21
+ try { value = typeof session?.getThinkingLevel === "function" ? session.getThinkingLevel() : undefined; }
22
+ catch { return null; }
23
+ if (value === undefined || value === null || value === "") value = session?.thinkingLevel;
24
+ const level = String(value ?? "");
25
+ return THINKING_LEVELS.has(level) ? level : null;
26
+ }
27
+
28
+ export async function configureSessionOptions(session: any, modelRef: string | null,
29
+ thinkingLevel: string): Promise<EffectiveSessionOptions> {
30
+ if (modelRef) {
31
+ const models = session.modelRuntime?.getAvailableSnapshot?.() ?? [];
32
+ const model = models.find((value: any) => webUiModelRef(String(value.provider ?? ""),
33
+ String(value.id ?? value.modelId ?? "")) === modelRef);
34
+ try { if (model) await session.setModel(model); } catch { /* Canonical read-back below decides the effect. */ }
35
+ }
36
+ try { await session.setThinkingLevel(thinkingLevel); } catch { /* Canonical read-back below decides the effect. */ }
37
+ const effectiveModel = effectiveModelRef(session), effectiveThinking = effectiveThinkingLevel(session);
38
+ if (!effectiveModel) return { state: "unknown", modelRef: null,
39
+ thinkingLevel: effectiveThinking, reasonCode: "session-model-effect-unknown" };
40
+ if (!effectiveThinking) return { state: "unknown", modelRef: effectiveModel,
41
+ thinkingLevel: null, reasonCode: "session-thinking-effect-unknown" };
42
+ if (modelRef && effectiveModel !== modelRef) return { state: "mismatch", modelRef: effectiveModel,
43
+ thinkingLevel: effectiveThinking, reasonCode: "session-model-mismatch" };
44
+ if (effectiveThinking !== thinkingLevel) return { state: "mismatch", modelRef: effectiveModel,
45
+ thinkingLevel: effectiveThinking, reasonCode: "session-thinking-mismatch" };
46
+ return { state: "confirmed", modelRef: effectiveModel, thinkingLevel: effectiveThinking, reasonCode: null };
47
+ }
@@ -0,0 +1,21 @@
1
+ import type { PiagentWebUICanonicalVolatileSessionOperationStateV1 } from "../contracts/generated/session-live-state-v1.ts";
2
+ import type { GatewayTerminalSettlement } from "./gateway-events.ts";
3
+ import type { CurrentOperationProjection } from "./session-runtime-supervisor.ts";
4
+
5
+ export function buildSessionLiveState(options: { gatewayInstanceRef: string; eventSequence: number;
6
+ operations: Array<CurrentOperationProjection & { sessionRef: string }>; settlements: GatewayTerminalSettlement[];
7
+ now?: Date }): PiagentWebUICanonicalVolatileSessionOperationStateV1 {
8
+ const now = options.now ?? new Date();
9
+ if (!Number.isFinite(now.getTime())) throw new Error("session-live-state-time-invalid");
10
+ return {
11
+ schemaVersion: 1,
12
+ version: "piagent-session-live-state-v1",
13
+ generatedAt: now.toISOString(),
14
+ gatewayInstanceRef: options.gatewayInstanceRef,
15
+ eventSequence: options.eventSequence,
16
+ state: "ready",
17
+ operations: [...options.operations].sort((left, right) => left.sessionRef.localeCompare(right.sessionRef)),
18
+ settlements: [...options.settlements].sort((left, right) => right.sequence - left.sequence).slice(0, 100),
19
+ reasonCode: null
20
+ };
21
+ }
@@ -12,7 +12,10 @@ export async function waitForOperationStart(
12
12
  prompt.then(() => ({ state: "prompt-resolved" as const }),
13
13
  (error: unknown) => ({ state: "prompt-rejected" as const, error }))
14
14
  ]);
15
- if (first.state === "started") return "prompt-owned";
15
+ if (first.state === "started") {
16
+ if (stream.lifecycleTerminationReasonCode) throw new Error(stream.lifecycleTerminationReasonCode);
17
+ return "prompt-owned";
18
+ }
16
19
  if (first.state === "prompt-rejected") throw first.error;
17
20
 
18
21
  // Extension commands such as `/workflow` launch their agent turn through
@@ -27,6 +30,7 @@ export async function waitForOperationStart(
27
30
  DEFERRED_OPERATION_START_GRACE_MS);
28
31
  })
29
32
  ]);
33
+ if (stream.lifecycleTerminationReasonCode) throw new Error(stream.lifecycleTerminationReasonCode);
30
34
  return "deferred";
31
35
  } finally {
32
36
  if (timer) clearTimeout(timer);
@@ -0,0 +1,158 @@
1
+ export type SessionOperationDeadlineReason = "operation-inactivity-timeout" | "operation-deadline-exceeded";
2
+
3
+ export type SessionOperationDeadlinePolicy = Readonly<{
4
+ inactivityTimeoutMs: number;
5
+ maximumDurationMs: number;
6
+ terminationTimeoutMs: number;
7
+ projectionTimeoutMs: number;
8
+ }>;
9
+
10
+ export type SessionOperationWatchdogOptions = Partial<SessionOperationDeadlinePolicy>;
11
+ export type BoundedResult<T> = { state: "settled"; value: T } | { state: "rejected"; error: unknown } | { state: "timeout" };
12
+ export type SessionOperationTerminationResult = { state: "settled" } | { state: "quarantine"; reasonCode: string };
13
+
14
+ export function bestEffortUnsubscribe(unsubscribe: (() => void) | null): void {
15
+ try { unsubscribe?.(); } catch { /* broken host cleanup cannot retain operation authority */ }
16
+ }
17
+
18
+ // A silent tool/provider gets 15 minutes. Any observed host event refreshes that
19
+ // allowance, while the six-hour ceiling prevents an endless stream of progress
20
+ // events from retaining runtime authority forever. Cleanup itself is separately
21
+ // bounded so Stop and watchdog recovery cannot inherit the original hang.
22
+ const DEFAULT_POLICY: SessionOperationDeadlinePolicy = Object.freeze({
23
+ inactivityTimeoutMs: 15 * 60_000,
24
+ maximumDurationMs: 6 * 60 * 60_000,
25
+ terminationTimeoutMs: 5_000,
26
+ projectionTimeoutMs: 2_500
27
+ });
28
+ const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
29
+ const TERMINATION_CLEANUP_FAILED = "operation-termination-cleanup-failed";
30
+
31
+ function duration(value: unknown, fallback: number): number {
32
+ if (value === undefined) return fallback;
33
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > MAX_TIMEOUT_MS) {
34
+ throw new Error("session-operation-watchdog-config-invalid");
35
+ }
36
+ return Number(value);
37
+ }
38
+
39
+ export function sessionOperationDeadlinePolicy(options: SessionOperationWatchdogOptions = {}): SessionOperationDeadlinePolicy {
40
+ const policy = {
41
+ inactivityTimeoutMs: duration(options.inactivityTimeoutMs, DEFAULT_POLICY.inactivityTimeoutMs),
42
+ maximumDurationMs: duration(options.maximumDurationMs, DEFAULT_POLICY.maximumDurationMs),
43
+ terminationTimeoutMs: duration(options.terminationTimeoutMs, DEFAULT_POLICY.terminationTimeoutMs),
44
+ projectionTimeoutMs: duration(options.projectionTimeoutMs, DEFAULT_POLICY.projectionTimeoutMs)
45
+ };
46
+ if (policy.projectionTimeoutMs > policy.terminationTimeoutMs) throw new Error("session-operation-watchdog-config-invalid");
47
+ return Object.freeze(policy);
48
+ }
49
+
50
+ export async function boundedResult<T>(promise: PromiseLike<T>, timeoutMs: number): Promise<BoundedResult<T>> {
51
+ let timer: ReturnType<typeof setTimeout> | undefined;
52
+ try {
53
+ return await Promise.race([
54
+ Promise.resolve(promise).then((value) => ({ state: "settled" as const, value }),
55
+ (error: unknown) => ({ state: "rejected" as const, error })),
56
+ new Promise<{ state: "timeout" }>((resolve) => { timer = setTimeout(() => resolve({ state: "timeout" }), timeoutMs); })
57
+ ]);
58
+ } finally {
59
+ if (timer) clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ export class SessionOperationWatchdog {
64
+ readonly policy: SessionOperationDeadlinePolicy;
65
+ #idleTimer: ReturnType<typeof setTimeout> | null = null;
66
+ #maximumTimer: ReturnType<typeof setTimeout> | null = null;
67
+ #expire: ((reason: SessionOperationDeadlineReason) => void) | null = null;
68
+ #closed = false;
69
+ #termination: Promise<unknown> | null = null;
70
+
71
+ constructor(policy: SessionOperationDeadlinePolicy) { this.policy = policy; }
72
+ get terminating(): boolean { return this.#termination !== null; }
73
+
74
+ start(expire: (reason: SessionOperationDeadlineReason) => void): void {
75
+ if (this.#expire || this.#closed) throw new Error("session-operation-watchdog-state-invalid");
76
+ this.#expire = expire;
77
+ this.#armIdle();
78
+ this.#maximumTimer = setTimeout(() => this.#deadline("operation-deadline-exceeded"), this.policy.maximumDurationMs);
79
+ // An accepted operation owns live runtime work until it settles. Keep both
80
+ // deadline timers referenced so a headless Gateway cannot let Node exit
81
+ // while the provider promise is pending and silently skip recovery.
82
+ }
83
+
84
+ progress(): void {
85
+ if (this.#closed || !this.#expire) return;
86
+ this.#armIdle();
87
+ }
88
+
89
+ close(): void {
90
+ if (this.#closed) return;
91
+ this.#closed = true;
92
+ if (this.#idleTimer) clearTimeout(this.#idleTimer);
93
+ if (this.#maximumTimer) clearTimeout(this.#maximumTimer);
94
+ this.#idleTimer = null; this.#maximumTimer = null; this.#expire = null;
95
+ }
96
+
97
+ terminate<T>(run: () => Promise<T>): Promise<T> {
98
+ if (!this.#termination) { this.close(); this.#termination = Promise.resolve().then(run); }
99
+ return this.#termination as Promise<T>;
100
+ }
101
+
102
+ #armIdle(): void {
103
+ if (this.#idleTimer) clearTimeout(this.#idleTimer);
104
+ this.#idleTimer = setTimeout(() => this.#deadline("operation-inactivity-timeout"), this.policy.inactivityTimeoutMs);
105
+ }
106
+
107
+ #deadline(reason: SessionOperationDeadlineReason): void {
108
+ if (this.#closed) return;
109
+ const expire = this.#expire; this.close(); expire?.(reason);
110
+ }
111
+ }
112
+
113
+ export function armSessionOperationWatchdog(options: {
114
+ watchdog: SessionOperationWatchdog;
115
+ subscribe(listener: (event: unknown) => void): unknown;
116
+ observe(event: unknown): void;
117
+ expire(reason: SessionOperationDeadlineReason): void;
118
+ }): () => void {
119
+ const unsubscribe = options.subscribe((event) => { options.watchdog.progress(); options.observe(event); });
120
+ if (typeof unsubscribe !== "function") throw new Error("session-operation-subscribe-invalid");
121
+ try { options.watchdog.start(options.expire); }
122
+ catch (error) { try { unsubscribe(); } catch { /* authority is quarantined by the caller */ } throw error; }
123
+ return () => { unsubscribe(); };
124
+ }
125
+
126
+ export async function terminateWatchedSessionOperation(options: {
127
+ watchdog: SessionOperationWatchdog;
128
+ settlement: "aborted" | "error";
129
+ reasonCode: string;
130
+ forcedReasonCode: string;
131
+ stream: { markAborted(reasonCode: string): void; markError(reasonCode: string): void;
132
+ forceLifecycleTermination(reasonCode: string): void };
133
+ completion(): Promise<void> | null;
134
+ settledCleanly(): boolean;
135
+ cancelApproval(reasonCode: string): void;
136
+ abortHost(): unknown;
137
+ clearQueue?(): void;
138
+ }): Promise<SessionOperationTerminationResult> {
139
+ return await options.watchdog.terminate(async () => {
140
+ const mark = (reasonCode: string) => options.settlement === "aborted"
141
+ ? options.stream.markAborted(reasonCode) : options.stream.markError(reasonCode);
142
+ let cleanupFailed = false;
143
+ try { options.cancelApproval(options.reasonCode); } catch { cleanupFailed = true; }
144
+ try { options.clearQueue?.(); } catch { cleanupFailed = true; }
145
+ const terminalReason = cleanupFailed ? TERMINATION_CLEANUP_FAILED : options.reasonCode;
146
+ mark(terminalReason);
147
+ void Promise.resolve().then(options.abortHost).catch(() => undefined);
148
+ const completion = options.completion();
149
+ const completed = completion ? await boundedResult(completion, options.watchdog.policy.terminationTimeoutMs)
150
+ : { state: "timeout" as const };
151
+ if (!cleanupFailed && completed.state === "settled" && options.settledCleanly()) return { state: "settled" as const };
152
+ const quarantineReason = cleanupFailed ? TERMINATION_CLEANUP_FAILED : options.forcedReasonCode;
153
+ mark(quarantineReason); options.stream.forceLifecycleTermination(quarantineReason);
154
+ const forcedCompletion = options.completion();
155
+ if (forcedCompletion) await boundedResult(forcedCompletion, options.watchdog.policy.projectionTimeoutMs);
156
+ return { state: "quarantine" as const, reasonCode: quarantineReason };
157
+ });
158
+ }