@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,45 @@
1
+ import path from "node:path";
2
+
3
+ import type { PiSessionInfo } from "./session-catalog.ts";
4
+ import { preferAuthoritativePiagentGuard } from "./extension-authority.ts";
5
+ import { rpcUiContext } from "./rpc-ui-context.ts";
6
+
7
+ export type RuntimeHandle = { dispose(): Promise<void>; session?: any };
8
+ export type RuntimeFactory = (info: PiSessionInfo, runtimeInstanceRef: string,
9
+ sessionManager?: any) => Promise<RuntimeHandle>;
10
+
11
+ export function createProductionRuntimeFactory(options: {
12
+ host: any;
13
+ agentDir: string;
14
+ packageRoot: string;
15
+ modelRuntime?: any;
16
+ }): RuntimeFactory {
17
+ return async (info, _runtimeInstanceRef, sessionManager) => {
18
+ const guard = path.join(options.packageRoot, "packages", "piagent-core", "extensions", "piagent-guard.ts");
19
+ const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }: any) => {
20
+ const services = await options.host.createAgentSessionServices({
21
+ cwd, agentDir, modelRuntime: options.modelRuntime,
22
+ resourceLoaderOptions: {
23
+ additionalExtensionPaths: [guard],
24
+ extensionsOverride: preferAuthoritativePiagentGuard(guard)
25
+ }
26
+ });
27
+ const extensionErrors = services.resourceLoader.getExtensions().errors;
28
+ if (extensionErrors.length) throw new Error("session-runtime-extension-load-failed");
29
+ const created = await options.host.createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
30
+ return { ...created, services, diagnostics: services.diagnostics };
31
+ };
32
+ const manager = sessionManager ?? options.host.SessionManager.open(info.path);
33
+ const runtime = await options.host.createAgentSessionRuntime(createRuntime, {
34
+ cwd: info.cwd,
35
+ agentDir: options.agentDir,
36
+ sessionManager: manager,
37
+ sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile: info.path }
38
+ });
39
+ const bind = async (session: any) => session.bindExtensions({ mode: "rpc", uiContext: rpcUiContext() });
40
+ runtime.setRebindSession(bind);
41
+ try { await bind(runtime.session); }
42
+ catch (error) { await runtime.dispose().catch(() => undefined); throw error; }
43
+ return runtime;
44
+ };
45
+ }
@@ -1,8 +1,5 @@
1
1
  import { createHmac, randomBytes } from "node:crypto";
2
- import path from "node:path";
3
-
4
- import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
5
- import { webUiTaskRevision } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
2
+ import { webUiModelRef, webUiTaskRevision } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
6
3
  import { activeSessionTask } from "../../piagent-core/extensions/task-state.js";
7
4
  import { inspectTaskControlState } from "../../piagent-core/runtime/inspection/task-control-journal.ts";
8
5
  import { piApprovalBroker, type ApprovalAuthority, type ApprovalBrokerEvent } from "../../piagent-core/runtime/inspection/approval-broker.ts";
@@ -11,52 +8,24 @@ import { isUserConversationSession, projectRefForCwd, sessionRefForPath } from "
11
8
  import { SessionLeaseStore, type SessionLeaseSnapshot } from "./session-lease-store.ts";
12
9
  import { GatewayEventStore } from "./gateway-events.ts";
13
10
  import { GatewaySessionStream } from "./gateway-session-stream.ts";
14
- import { preferAuthoritativePiagentGuard } from "./extension-authority.ts";
15
11
  import { executePermissionCommand, executeRuntimeCommand } from "./runtime-session-controls.ts";
16
- import { rpcUiContext } from "./rpc-ui-context.ts";
12
+ import { configureSessionOptions, effectiveModelRef, effectiveThinkingLevel,
13
+ type EffectiveSessionOptions } from "./session-effective-options.ts";
17
14
  import { waitForOperationStart } from "./session-operation-start.ts";
18
-
15
+ import { armSessionOperationWatchdog, bestEffortUnsubscribe, boundedResult, sessionOperationDeadlinePolicy, SessionOperationWatchdog, terminateWatchedSessionOperation,
16
+ type SessionOperationDeadlinePolicy, type SessionOperationWatchdogOptions } from "./session-operation-watchdog.ts";
17
+ import { createProductionRuntimeFactory, type RuntimeFactory, type RuntimeHandle } from "./session-runtime-factory.ts";
19
18
  const MAX_WARM_RUNTIMES = 10;
20
-
21
- type RuntimeHandle = { dispose(): Promise<void>; session?: any };
22
- type RuntimeFactory = (info: PiSessionInfo, runtimeInstanceRef: string, sessionManager?: any) => Promise<RuntimeHandle>;
23
19
  type ActiveRuntime = { runtime: RuntimeHandle; lease: SessionLeaseSnapshot; info: PiSessionInfo; operationRef: string | null;
24
- unsubscribe: (() => void) | null; completion: Promise<void> | null; settling: boolean; approvalWaiting: boolean;
25
- unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null; sessionManager: any | null };
20
+ stream: GatewaySessionStream | null; unsubscribe: (() => void) | null; completion: Promise<void> | null; settling: boolean; approvalWaiting: boolean;
21
+ unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null; sessionManager: any | null;
22
+ watchdog: SessionOperationWatchdog | null; lastSessionRevision: string | null };
26
23
  type Projection = { sessionRevision: string; liveState: "offline" | "idle" | "running" | "paused" | "waiting-approval" | "uncertain" };
27
24
  type RuntimeCommandResult = Awaited<ReturnType<typeof executeRuntimeCommand>>;
28
-
29
- function productionRuntimeFactory(options: { host: any; agentDir: string; packageRoot: string; modelRuntime?: any }): RuntimeFactory {
30
- return async (info, _runtimeInstanceRef, sessionManager) => {
31
- const guard = path.join(options.packageRoot, "packages", "piagent-core", "extensions", "piagent-guard.ts");
32
- const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }: any) => {
33
- const services = await options.host.createAgentSessionServices({
34
- cwd, agentDir, modelRuntime: options.modelRuntime,
35
- resourceLoaderOptions: {
36
- additionalExtensionPaths: [guard],
37
- extensionsOverride: preferAuthoritativePiagentGuard(guard)
38
- }
39
- });
40
- const extensionErrors = services.resourceLoader.getExtensions().errors;
41
- if (extensionErrors.length) throw new Error("session-runtime-extension-load-failed");
42
- const created = await options.host.createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
43
- return { ...created, services, diagnostics: services.diagnostics };
44
- };
45
- const manager = sessionManager ?? options.host.SessionManager.open(info.path);
46
- const runtime = await options.host.createAgentSessionRuntime(createRuntime, {
47
- cwd: info.cwd,
48
- agentDir: options.agentDir,
49
- sessionManager: manager,
50
- sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile: info.path }
51
- });
52
- const bind = async (session: any) => session.bindExtensions({ mode: "rpc", uiContext: rpcUiContext() });
53
- runtime.setRebindSession(bind);
54
- try { await bind(runtime.session); }
55
- catch (error) { await runtime.dispose().catch(() => undefined); throw error; }
56
- return runtime;
57
- };
58
- }
59
-
25
+ export type { EffectiveSessionOptions } from "./session-effective-options.ts";
26
+ export type SessionCreateResult = { sessionRef: string; effectiveOptions: EffectiveSessionOptions };
27
+ export type CurrentOperationProjection = { operationRef: string; state: "running" | "waiting-approval" | "settling";
28
+ abortable: boolean };
60
29
  export class SessionRuntimeSupervisor {
61
30
  readonly #gatewayInstanceRef: string;
62
31
  readonly #key: Buffer;
@@ -65,13 +34,13 @@ export class SessionRuntimeSupervisor {
65
34
  readonly #runtimeFactory: RuntimeFactory;
66
35
  readonly #events: GatewayEventStore;
67
36
  readonly #host: any | null;
37
+ readonly #operationDeadlinePolicy: SessionOperationDeadlinePolicy;
68
38
  readonly #resolveProject: ((projectRef: string) => string | null) | null;
69
39
  readonly #active = new Map<string, ActiveRuntime>();
70
40
  readonly #opening = new Map<string, Promise<SessionLeaseSnapshot>>();
71
41
  readonly #created = new Map<string, PiSessionInfo>();
72
42
  #closed = false;
73
43
  #readProjection: ((sessionRef: string) => Promise<Projection>) | null = null;
74
-
75
44
  constructor(options: {
76
45
  gatewayInstanceRef: string;
77
46
  key: Buffer;
@@ -83,6 +52,7 @@ export class SessionRuntimeSupervisor {
83
52
  packageRoot?: string;
84
53
  modelRuntime?: any;
85
54
  events?: GatewayEventStore;
55
+ operationWatchdog?: SessionOperationWatchdogOptions;
86
56
  resolveProject?(projectRef: string): string | null;
87
57
  }) {
88
58
  this.#gatewayInstanceRef = options.gatewayInstanceRef;
@@ -91,19 +61,17 @@ export class SessionRuntimeSupervisor {
91
61
  this.#listSessions = options.listSessions;
92
62
  this.#events = options.events ?? new GatewayEventStore();
93
63
  this.#host = options.host ?? null;
64
+ this.#operationDeadlinePolicy = sessionOperationDeadlinePolicy(options.operationWatchdog);
94
65
  this.#resolveProject = options.resolveProject ?? null;
95
66
  if (options.runtimeFactory) this.#runtimeFactory = options.runtimeFactory;
96
67
  else {
97
68
  if (!options.host || !options.agentDir || !options.packageRoot) throw new Error("session-runtime-factory-unavailable");
98
- this.#runtimeFactory = productionRuntimeFactory({ host: options.host, agentDir: options.agentDir,
69
+ this.#runtimeFactory = createProductionRuntimeFactory({ host: options.host, agentDir: options.agentDir,
99
70
  packageRoot: options.packageRoot, modelRuntime: options.modelRuntime });
100
71
  }
101
72
  }
102
-
103
73
  get activeCount(): number { return this.#active.size; }
104
-
105
74
  setProjectionReader(reader: (sessionRef: string) => Promise<Projection>): void { this.#readProjection = reader; }
106
-
107
75
  async listSessions(): Promise<PiSessionInfo[]> {
108
76
  const persisted = (await this.#listSessions()).filter(isUserConversationSession);
109
77
  const paths = new Set(persisted.map((info) => info.path));
@@ -112,8 +80,11 @@ export class SessionRuntimeSupervisor {
112
80
  }
113
81
  return [...persisted, ...[...this.#created.values()].filter((info) => !paths.has(info.path))];
114
82
  }
115
-
116
83
  async create(projectRef: string, placeRef: string, modelRef: string | null, thinkingLevel: string): Promise<string> {
84
+ return (await this.createWithReadback(projectRef, placeRef, modelRef, thinkingLevel)).sessionRef;
85
+ }
86
+ async createWithReadback(projectRef: string, placeRef: string, modelRef: string | null,
87
+ thinkingLevel: string): Promise<SessionCreateResult> {
117
88
  if (this.#closed || !this.#host) throw new Error("session-create-unavailable");
118
89
  if (placeRef !== projectRef) throw new Error("session-place-mismatch");
119
90
  const sessions = (await this.#listSessions()).filter(isUserConversationSession);
@@ -128,22 +99,19 @@ export class SessionRuntimeSupervisor {
128
99
  const created: PiSessionInfo = { path: sessionFile, id: manager.getSessionId(), cwd: candidates[0], created: createdAt,
129
100
  modified: createdAt, messageCount: 0, firstMessage: "(no messages)", allMessagesText: "" };
130
101
  const sessionRef = sessionRefForPath(this.#key, sessionFile);
131
- await this.#activate(sessionRef, created, manager);
132
- const active = this.#active.get(sessionRef), session = active?.runtime.session;
133
- if (!session) throw new Error("session-runtime-unavailable");
134
- if (modelRef) {
135
- const models = session.modelRuntime?.getAvailableSnapshot?.() ?? [];
136
- const model = models.find((value: any) => webUiModelRef(String(value.provider ?? ""), String(value.id ?? "")) === modelRef);
137
- if (!model) throw new Error("session-model-unavailable");
138
- await session.setModel(model);
139
- }
140
- session.setThinkingLevel(thinkingLevel);
102
+ // Retain identity before fallible runtime work so create receipts never point at an orphan.
141
103
  this.#created.set(sessionRef, created);
142
- return sessionRef;
104
+ try { await this.#activate(sessionRef, created, manager); }
105
+ catch {
106
+ return { sessionRef, effectiveOptions: { state: "unknown", modelRef: null, thinkingLevel: null,
107
+ reasonCode: "session-runtime-open-failed" } };
108
+ }
109
+ const active = this.#active.get(sessionRef), session = active?.runtime.session;
110
+ if (!session) return { sessionRef, effectiveOptions: { state: "unknown", modelRef: null, thinkingLevel: null,
111
+ reasonCode: "session-runtime-unavailable" } };
112
+ return { sessionRef, effectiveOptions: await configureSessionOptions(session, modelRef, thinkingLevel) };
143
113
  }
144
-
145
114
  liveSessionManager(sessionRef: string): any | null { return this.#active.get(sessionRef)?.sessionManager ?? null; }
146
-
147
115
  async acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
148
116
  if (this.#closed) throw new Error("session-runtime-supervisor-closed");
149
117
  const active = this.#active.get(sessionRef);
@@ -154,7 +122,6 @@ export class SessionRuntimeSupervisor {
154
122
  this.#opening.set(sessionRef, opening);
155
123
  return await opening;
156
124
  }
157
-
158
125
  async #acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
159
126
  if (this.#active.size + this.#opening.size > MAX_WARM_RUNTIMES) throw new Error("session-runtime-capacity");
160
127
  const found = (await this.#listSessions()).filter(isUserConversationSession)
@@ -162,7 +129,6 @@ export class SessionRuntimeSupervisor {
162
129
  if (found.length !== 1) throw new Error(found.length ? "session-ref-ambiguous" : "session-not-found");
163
130
  return await this.#activate(sessionRef, found[0]!);
164
131
  }
165
-
166
132
  async #activate(sessionRef: string, info: PiSessionInfo, sessionManager?: any): Promise<SessionLeaseSnapshot> {
167
133
  if (this.#active.has(sessionRef)) throw new Error("session-owner-conflict");
168
134
  const runtimeInstanceRef = `runtime_${randomBytes(24).toString("base64url")}`;
@@ -179,9 +145,9 @@ export class SessionRuntimeSupervisor {
179
145
  await runtime.dispose().catch(() => undefined);
180
146
  throw new Error("session-owner-continuity-lost");
181
147
  }
182
- const active: ActiveRuntime = { runtime, lease: current, info, operationRef: null, unsubscribe: null, completion: null,
148
+ const active: ActiveRuntime = { runtime, lease: current, info, operationRef: null, stream: null, unsubscribe: null, completion: null,
183
149
  settling: false, approvalWaiting: false, unbindApproval: null, unsubscribeApproval: null,
184
- sessionManager: sessionManager ?? runtime.session?.sessionManager ?? null };
150
+ sessionManager: sessionManager ?? runtime.session?.sessionManager ?? null, watchdog: null, lastSessionRevision: null };
185
151
  this.#active.set(sessionRef, active);
186
152
  this.#bindApproval(sessionRef, active);
187
153
  return current;
@@ -191,7 +157,6 @@ export class SessionRuntimeSupervisor {
191
157
  throw error;
192
158
  }
193
159
  }
194
-
195
160
  async release(sessionRef: string): Promise<SessionLeaseSnapshot> {
196
161
  const active = this.#active.get(sessionRef);
197
162
  if (!active) {
@@ -203,7 +168,7 @@ export class SessionRuntimeSupervisor {
203
168
  if (active.settling && active.completion) await active.completion;
204
169
  if (active.operationRef || active.settling) throw new Error("session-runtime-busy");
205
170
  this.#active.delete(sessionRef);
206
- active.unsubscribeApproval?.(); active.unbindApproval?.();
171
+ bestEffortUnsubscribe(active.unsubscribeApproval); bestEffortUnsubscribe(active.unbindApproval);
207
172
  try { await active.runtime.dispose(); }
208
173
  catch (error) {
209
174
  try { this.#leases.requireRecovery(sessionRef, active.lease.ownerEpoch!, this.#gatewayInstanceRef,
@@ -215,7 +180,28 @@ export class SessionRuntimeSupervisor {
215
180
  if (transient && !(await this.#listSessions()).some((info) => info.path === transient.path)) this.#created.delete(sessionRef);
216
181
  return released;
217
182
  }
218
-
183
+ async restart(sessionRef: string): Promise<SessionLeaseSnapshot> {
184
+ const active = this.#active.get(sessionRef);
185
+ if (!active) return await this.acquire(sessionRef);
186
+ if (active.operationRef || active.settling || !active.runtime.session?.isIdle) throw new Error("session-runtime-busy");
187
+ const info = active.info, manager = active.sessionManager; await this.release(sessionRef);
188
+ return await this.#activate(sessionRef, info, manager ?? undefined);
189
+ }
190
+ currentOperation(sessionRef: string): CurrentOperationProjection | null {
191
+ const active = this.#active.get(sessionRef);
192
+ if (!active?.operationRef) return null;
193
+ return { operationRef: active.operationRef,
194
+ state: active.settling ? "settling" : active.approvalWaiting ? "waiting-approval" : "running",
195
+ abortable: !active.settling };
196
+ }
197
+ currentOperations(): Array<CurrentOperationProjection & { sessionRef: string }> {
198
+ const result: Array<CurrentOperationProjection & { sessionRef: string }> = [];
199
+ for (const sessionRef of this.#active.keys()) {
200
+ const operation = this.currentOperation(sessionRef);
201
+ if (operation) result.push({ sessionRef, ...operation });
202
+ }
203
+ return result;
204
+ }
219
205
  ownership(sessionRef: string): SessionOwnerProjection {
220
206
  const lease = this.#leases.inspect(sessionRef);
221
207
  const active = this.#active.get(sessionRef);
@@ -225,7 +211,7 @@ export class SessionRuntimeSupervisor {
225
211
  reasonCode: null
226
212
  };
227
213
  if (lease.state === "gateway-owned" && active && active.lease.ownerEpoch === lease.ownerEpoch
228
- && lease.gatewayInstanceRef === this.#gatewayInstanceRef) return {
214
+ && lease.gatewayInstanceRef === this.#gatewayInstanceRef && active.lease.runtimeInstanceRef === lease.runtimeInstanceRef) return {
229
215
  state: "gateway-owned", liveState: active.approvalWaiting ? "waiting-approval" : active.operationRef ? "running" : "idle",
230
216
  composerAvailable: true, needsAttention: active.approvalWaiting,
231
217
  owner: { kind: "gateway", ownerEpoch: lease.ownerEpoch!, gatewayInstanceRef: lease.gatewayInstanceRef!,
@@ -250,10 +236,6 @@ export class SessionRuntimeSupervisor {
250
236
  reasonCode: lease.reasonCode ?? "session-owner-continuity-unknown"
251
237
  };
252
238
  }
253
-
254
- // `message` arrives already carrying any attached document text, and `images`
255
- // already split out: the command controller claims staged refs before it calls
256
- // here, so this layer only forwards what the host prompt takes.
257
239
  async send(sessionRef: string, payload: { delivery: "new-operation" | "follow-up" | "steer"; message: string;
258
240
  expectedOperationRef: string | null; images?: unknown[] }, sessionRevision: string):
259
241
  Promise<{ resultCode: "started" | "queued" | "steered"; operationRef: string }> {
@@ -261,7 +243,7 @@ export class SessionRuntimeSupervisor {
261
243
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
262
244
  if (!active || !session) throw new Error("session-runtime-unavailable");
263
245
  if (payload.delivery !== "new-operation") {
264
- if (!active.operationRef || active.operationRef !== payload.expectedOperationRef || !session.isStreaming) throw new Error("session-operation-conflict");
246
+ if (!active.operationRef || active.operationRef !== payload.expectedOperationRef || active.settling || !session.isStreaming) throw new Error("session-operation-conflict");
265
247
  const images = payload.images?.length ? payload.images : undefined;
266
248
  if (payload.delivery === "follow-up") { await session.followUp(payload.message, images); return { resultCode: "queued", operationRef: active.operationRef }; }
267
249
  await session.steer(payload.message, images); return { resultCode: "steered", operationRef: active.operationRef };
@@ -269,8 +251,14 @@ export class SessionRuntimeSupervisor {
269
251
  if (payload.expectedOperationRef !== null || active.operationRef || active.settling || !session.isIdle) throw new Error("session-operation-conflict");
270
252
  const operationRef = `operation_${randomBytes(24).toString("base64url")}`;
271
253
  const stream = new GatewaySessionStream({ sessionRef, operationRef, events: this.#events });
272
- active.operationRef = operationRef;
273
- active.unsubscribe = session.subscribe((event: unknown) => stream.observe(event));
254
+ const watchdog = new SessionOperationWatchdog(this.#operationDeadlinePolicy);
255
+ active.operationRef = operationRef; active.stream = stream; active.watchdog = watchdog; active.lastSessionRevision = sessionRevision;
256
+ try {
257
+ active.unsubscribe = armSessionOperationWatchdog({ watchdog, subscribe: (listener) => session.subscribe(listener),
258
+ observe: (event) => stream.observe(event), expire: (reasonCode) => { void this.#terminateOperation(sessionRef,
259
+ operationRef, "error", reasonCode, reasonCode, true).catch(() => undefined); } });
260
+ } catch (error) { stream.markError("session-operation-start-failed");
261
+ await this.#quarantineRuntime(sessionRef, operationRef, active, stream, "session-operation-start-failed"); throw error; }
274
262
  this.#events.publish("runtime.changed", { sessionRef, sessionRevision, liveState: "running", operationRef, reasonCode: null });
275
263
  const created = this.#created.get(sessionRef);
276
264
  if (created && created.firstMessage === "(no messages)") {
@@ -278,31 +266,51 @@ export class SessionRuntimeSupervisor {
278
266
  }
279
267
  let prompt: Promise<void>;
280
268
  try { prompt = session.prompt(payload.message, payload.images?.length ? { images: payload.images } : undefined); }
281
- catch (error) { await this.#finishOperation(sessionRef, operationRef, stream); throw error; }
269
+ catch (error) { stream.markError(); await this.#finishOperation(sessionRef, operationRef, stream); throw error; }
282
270
  const started = waitForOperationStart(stream, prompt);
283
- // A normal prompt resolves after the agent settles. A workflow extension
284
- // command resolves before its nested agent turn starts. In both cases the
285
- // canonical completion boundary is `agent_settled`, not the outer promise.
271
+ // `agent_settled` is canonical even when a workflow's outer prompt resolves first.
286
272
  const lifecycle = started.then(async (mode) => {
287
273
  if (mode === "deferred") await stream.settled();
288
274
  else await Promise.race([stream.settled(), prompt]);
289
275
  });
290
276
  active.completion = lifecycle.then(() => this.#finishOperation(sessionRef, operationRef, stream),
291
- () => this.#finishOperation(sessionRef, operationRef, stream));
277
+ () => { stream.markError(); return this.#finishOperation(sessionRef, operationRef, stream); });
292
278
  try { await started; }
293
279
  catch (error) { await active.completion; throw error; }
294
280
  return { resultCode: "started", operationRef };
295
281
  }
296
-
297
282
  async abort(sessionRef: string, operationRef: string, clearQueued: boolean): Promise<void> {
298
- const active = this.#active.get(sessionRef), session = active?.runtime.session;
299
- if (!active || !session || active.operationRef !== operationRef) throw new Error("session-operation-conflict");
300
- const completion = active.completion;
301
- await session.abort();
302
- if (clearQueued) session.clearQueue();
303
- await completion;
283
+ await this.#terminateOperation(sessionRef, operationRef, "aborted", "operation-aborted", "operation-abort-timeout", clearQueued);
284
+ }
285
+ async #terminateOperation(sessionRef: string, operationRef: string, settlement: "aborted" | "error",
286
+ reasonCode: string, forcedReasonCode: string, clearQueued: boolean): Promise<void> {
287
+ const active = this.#active.get(sessionRef), session = active?.runtime.session, stream = active?.stream, watchdog = active?.watchdog;
288
+ if (!active || !session || !stream || !watchdog || active.operationRef !== operationRef) throw new Error("session-operation-conflict");
289
+ if (active.settling && !watchdog.terminating) throw new Error("session-operation-conflict");
290
+ active.settling = true; active.approvalWaiting = false;
291
+ const result = await terminateWatchedSessionOperation({ watchdog, settlement, reasonCode, forcedReasonCode, stream,
292
+ completion: () => active.completion,
293
+ settledCleanly: () => this.#active.get(sessionRef) === active && active.operationRef !== operationRef
294
+ && session.isIdle === true && session.isStreaming !== true,
295
+ cancelApproval: (reason) => { piApprovalBroker.cancelForOperation(active.info.cwd, active.info.id, operationRef, reason); },
296
+ abortHost: () => session.abort(), clearQueue: clearQueued ? () => session.clearQueue() : undefined
297
+ });
298
+ if (result.state === "quarantine") await this.#quarantineRuntime(sessionRef, operationRef, active, stream, result.reasonCode);
299
+ else if (this.#active.get(sessionRef) === active) { active.stream = null; active.watchdog = null; active.settling = false; }
300
+ }
301
+ async #quarantineRuntime(sessionRef: string, operationRef: string, active: ActiveRuntime,
302
+ stream: GatewaySessionStream, reasonCode: string): Promise<void> {
303
+ if (this.#active.get(sessionRef) !== active) return;
304
+ bestEffortUnsubscribe(active.unsubscribe); active.unsubscribe = null; active.watchdog?.close(); active.watchdog = null;
305
+ if (active.operationRef === operationRef) { active.operationRef = null; stream.complete(null); }
306
+ active.stream = null; active.completion = null; active.settling = false; active.approvalWaiting = false;
307
+ this.#active.delete(sessionRef); bestEffortUnsubscribe(active.unsubscribeApproval); bestEffortUnsubscribe(active.unbindApproval);
308
+ try { this.#leases.requireRecovery(sessionRef, active.lease.ownerEpoch!, this.#gatewayInstanceRef,
309
+ active.lease.runtimeInstanceRef!, reasonCode); } catch { /* continuity remains fail closed */ }
310
+ if (active.lastSessionRevision) this.#events.publish("runtime.changed", { sessionRef,
311
+ sessionRevision: active.lastSessionRevision, liveState: "uncertain", operationRef: null, reasonCode });
312
+ await boundedResult(Promise.resolve().then(() => active.runtime.dispose()), this.#operationDeadlinePolicy.terminationTimeoutMs);
304
313
  }
305
-
306
314
  #bindApproval(sessionRef: string, active: ActiveRuntime): void {
307
315
  const authority = (): ApprovalAuthority => {
308
316
  if (!active.operationRef || active.lease.state !== "gateway-owned" || !active.lease.revision) return null;
@@ -327,50 +335,53 @@ export class SessionRuntimeSupervisor {
327
335
  active.unbindApproval = piApprovalBroker.bind({ cwd: active.info.cwd, rawSessionId: active.info.id,
328
336
  runtimeInstanceId: active.lease.runtimeInstanceRef!, authority });
329
337
  active.unsubscribeApproval = piApprovalBroker.subscribe(active.info.cwd, active.info.id, (event: ApprovalBrokerEvent) => {
338
+ active.watchdog?.progress();
330
339
  const projection = piApprovalBroker.projection(active.info.cwd, active.info.id);
331
340
  active.approvalWaiting = projection.summary.state === "waiting";
332
341
  void this.#publishApprovalState(sessionRef, active, event);
333
342
  });
334
343
  }
335
-
336
344
  async #publishApprovalState(sessionRef: string, active: ActiveRuntime, event: ApprovalBrokerEvent): Promise<void> {
337
345
  try {
338
346
  const projection = await this.#readProjection?.(sessionRef);
339
347
  if (!projection || this.#active.get(sessionRef) !== active) return;
348
+ active.lastSessionRevision = projection.sessionRevision;
340
349
  this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision,
341
350
  liveState: active.approvalWaiting ? "waiting-approval" : active.operationRef ? "running" : projection.liveState,
342
351
  operationRef: active.operationRef, reasonCode: `approval-${event.kind}` });
343
352
  } catch { /* approval truth remains available through the canonical inspection route */ }
344
353
  }
345
-
346
354
  async setModel(sessionRef: string, modelRef: string): Promise<"model-changed" | "no-change"> {
347
355
  await this.acquire(sessionRef);
348
356
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
349
357
  if (!active || !session) throw new Error("session-runtime-unavailable");
350
358
  if (active.operationRef || !session.isIdle) throw new Error("session-runtime-busy");
351
- const current = session.model && typeof session.model === "object"
352
- ? webUiModelRef(String(session.model.provider ?? ""), String(session.model.id ?? session.model.modelId ?? "")) : null;
359
+ const current = effectiveModelRef(session);
353
360
  if (current === modelRef) return "no-change";
354
361
  const models = session.modelRuntime?.getAvailableSnapshot?.() ?? [];
355
- const model = models.find((value: any) => webUiModelRef(String(value.provider ?? ""), String(value.id ?? "")) === modelRef);
362
+ const model = models.find((value: any) => webUiModelRef(String(value.provider ?? ""), String(value.id ?? value.modelId ?? "")) === modelRef);
356
363
  if (!model) throw new Error("session-model-unavailable");
357
364
  await session.setModel(model);
365
+ const effective = effectiveModelRef(session);
366
+ if (!effective) throw new Error("session-model-effect-unknown");
367
+ if (effective !== modelRef) throw new Error("session-model-mismatch");
358
368
  this.#touchCreated(sessionRef);
359
369
  return "model-changed";
360
370
  }
361
-
362
371
  async setThinking(sessionRef: string, thinkingLevel: string): Promise<"thinking-changed" | "no-change"> {
363
372
  await this.acquire(sessionRef);
364
373
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
365
374
  if (!active || !session) throw new Error("session-runtime-unavailable");
366
375
  if (active.operationRef || !session.isIdle) throw new Error("session-runtime-busy");
367
- const current = String(session.thinkingLevel ?? session.getThinkingLevel?.() ?? "unknown");
376
+ const current = effectiveThinkingLevel(session);
368
377
  if (current === thinkingLevel) return "no-change";
369
- session.setThinkingLevel(thinkingLevel);
378
+ await session.setThinkingLevel(thinkingLevel);
379
+ const effective = effectiveThinkingLevel(session);
380
+ if (!effective) throw new Error("session-thinking-effect-unknown");
381
+ if (effective !== thinkingLevel) throw new Error("session-thinking-mismatch");
370
382
  this.#touchCreated(sessionRef);
371
383
  return "thinking-changed";
372
384
  }
373
-
374
385
  async setPermission(sessionRef: string, permissionMode: "read-only" | "workspace-write" | "trusted-full-access"): Promise<"permission-changed"> {
375
386
  await this.acquire(sessionRef);
376
387
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
@@ -380,7 +391,6 @@ export class SessionRuntimeSupervisor {
380
391
  this.#touchCreated(sessionRef);
381
392
  return "permission-changed";
382
393
  }
383
-
384
394
  async runRuntimeCommand(sessionRef: string, command: string): Promise<RuntimeCommandResult> {
385
395
  await this.acquire(sessionRef);
386
396
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
@@ -394,12 +404,10 @@ export class SessionRuntimeSupervisor {
394
404
  health: { state: "unavailable", reasonCode: "approval-broker-unavailable", message: "Approval broker is unavailable" } } };
395
405
  return piApprovalBroker.projection(active.info.cwd, active.info.id);
396
406
  }
397
-
398
407
  approvalDetail(sessionRef: string, approvalRef: string): unknown | null {
399
408
  const active = this.#active.get(sessionRef);
400
409
  return active ? piApprovalBroker.detail(active.info.cwd, active.info.id, approvalRef) : null;
401
410
  }
402
-
403
411
  async decideApproval(approvalRef: string, decision: unknown): Promise<unknown> {
404
412
  for (const active of this.#active.values()) {
405
413
  if (piApprovalBroker.detail(active.info.cwd, active.info.id, approvalRef)) {
@@ -408,7 +416,6 @@ export class SessionRuntimeSupervisor {
408
416
  }
409
417
  throw new Error("approval-not-pending");
410
418
  }
411
-
412
419
  async rename(sessionRef: string, title: string): Promise<"renamed" | "no-change"> {
413
420
  await this.acquire(sessionRef);
414
421
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
@@ -420,7 +427,6 @@ export class SessionRuntimeSupervisor {
420
427
  this.#touchCreated(sessionRef);
421
428
  return "renamed";
422
429
  }
423
-
424
430
  async fork(sessionRef: string, entryRef: string | null, title: string | null): Promise<string> {
425
431
  if (!this.#host) throw new Error("session-fork-unavailable");
426
432
  await this.acquire(sessionRef);
@@ -447,28 +453,41 @@ export class SessionRuntimeSupervisor {
447
453
  this.#created.set(forkRef, created);
448
454
  return forkRef;
449
455
  }
450
-
451
- #touchCreated(sessionRef: string): void {
452
- const created = this.#created.get(sessionRef);
453
- if (created) created.modified = new Date();
454
- }
455
-
456
+ #touchCreated(sessionRef: string): void { const created = this.#created.get(sessionRef); if (created) created.modified = new Date(); }
456
457
  async #finishOperation(sessionRef: string, operationRef: string, stream: GatewaySessionStream): Promise<void> {
457
458
  const active = this.#active.get(sessionRef);
458
459
  if (!active || active.operationRef !== operationRef) return;
459
- active.unsubscribe?.(); active.unsubscribe = null; active.operationRef = null; active.settling = true;
460
+ const restartRequired = stream.runtimeRestartRequired; let projection: Projection | null = null;
461
+ bestEffortUnsubscribe(active.unsubscribe); active.unsubscribe = null; active.watchdog?.close(); active.settling = true;
460
462
  try {
461
- const projection = await this.#readProjection?.(sessionRef);
462
- if (!projection) return;
463
- stream.complete(projection.sessionRevision);
464
- this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision,
465
- liveState: projection.liveState, operationRef: null, reasonCode: null });
463
+ if (this.#readProjection) {
464
+ const read = await boundedResult(this.#readProjection(sessionRef), this.#operationDeadlinePolicy.projectionTimeoutMs);
465
+ if (read.state === "settled") projection = read.value;
466
+ else if (read.state === "timeout") stream.markError("session-projection-timeout");
467
+ }
466
468
  } catch { /* Canonical refresh failure cannot be replaced by an invented revision. */ }
467
- finally {
468
- if (this.#active.get(sessionRef) === active) { active.settling = false; active.completion = null; }
469
+ if (this.#active.get(sessionRef) !== active || active.operationRef !== operationRef) return;
470
+ if (this.ownership(sessionRef).state !== "gateway-owned") { stream.markError("session-owner-continuity-lost"); await this.#quarantineRuntime(sessionRef, operationRef, active, stream, "session-owner-continuity-lost"); return; }
471
+ active.operationRef = null;
472
+ if (projection) active.lastSessionRevision = projection.sessionRevision;
473
+ stream.complete(projection?.sessionRevision ?? null);
474
+ if (projection) this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision,
475
+ liveState: restartRequired ? "uncertain" : projection.liveState, operationRef: null,
476
+ reasonCode: restartRequired ? "runtime-restart-required" : null });
477
+ if (this.#active.get(sessionRef) === active) { active.completion = null;
478
+ if (!active.watchdog?.terminating) { active.stream = null; active.watchdog = null; active.settling = false; } }
479
+ if (!restartRequired || !projection || this.#active.get(sessionRef) !== active) return;
480
+ let restartAuthority: ActiveRuntime | null = active;
481
+ try {
482
+ await this.restart(sessionRef); restartAuthority = this.#active.get(sessionRef) ?? null;
483
+ const projection = await this.#readProjection?.(sessionRef);
484
+ if (!projection || !restartAuthority || this.#active.get(sessionRef) !== restartAuthority || this.ownership(sessionRef).state !== "gateway-owned") return;
485
+ this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision, liveState: projection.liveState, operationRef: null, reasonCode: null });
486
+ } catch {
487
+ if (!restartAuthority || this.#active.get(sessionRef) !== restartAuthority || this.ownership(sessionRef).state !== "gateway-owned") return;
488
+ this.#events.publish("runtime.changed", { sessionRef, sessionRevision: projection.sessionRevision, liveState: "uncertain", operationRef: null, reasonCode: "runtime-restart-failed" });
469
489
  }
470
490
  }
471
-
472
491
  async close(): Promise<void> {
473
492
  if (this.#closed) return;
474
493
  this.#closed = true;
@@ -56,6 +56,7 @@ export async function startLoopbackServer(options: {
56
56
  mode?: "session" | "gateway";
57
57
  readCapabilities: ReadCapabilities;
58
58
  readSessionCatalog?: () => unknown | Promise<unknown>;
59
+ readSessionLiveState?: () => unknown | Promise<unknown>;
59
60
  readSessionCreationOptions?: () => unknown | Promise<unknown>;
60
61
  readSessionModel?: (sessionRef: string) => WebUiReadModelProvider | Promise<WebUiReadModelProvider>;
61
62
  executeSessionAttachment?: (sessionRef: string, command: unknown) => unknown | Promise<unknown>;
@@ -296,6 +297,13 @@ export async function startLoopbackServer(options: {
296
297
  catch { return errorResponse(response, 503, "session-catalog-unavailable"); }
297
298
  }
298
299
 
300
+ if (request.method === "GET" && url.pathname === "/api/v1/session-live-state" && options.readSessionLiveState) {
301
+ if (!auth.authenticate(request)) return errorResponse(response, 401, "authentication-required");
302
+ if (url.search) return errorResponse(response, 400, "invalid-session-live-state-query");
303
+ try { return jsonResponse(response, 200, await options.readSessionLiveState()); }
304
+ catch { return errorResponse(response, 503, "session-live-state-unavailable"); }
305
+ }
306
+
299
307
  if (request.method === "GET" && url.pathname === "/api/v1/session-creation-options" && options.readSessionCreationOptions) {
300
308
  if (!auth.authenticate(request)) return errorResponse(response, 401, "authentication-required");
301
309
  if (url.search) return errorResponse(response, 400, "invalid-session-creation-query");
@@ -1,11 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  import { redactSensitiveText } from "../../piagent-core/extensions/redaction-core.js";
4
+ import { hasVisibleText } from "../shared/text-visibility.ts";
4
5
 
5
6
  const MAX_ENTRIES = 50_000;
6
7
  const MAX_ITEMS = 200;
7
8
  const MAX_TEXT = 16_384;
8
- const ANSI = /\u001b(?:[@-_]|\[[0-?]*[ -/]*[@-~])/g;
9
+ const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g;
10
+ const COMPLETION_GATE = /^\[Piagent completion gate: (CONTINUING|NOT APPROVED)\]/i;
9
11
 
10
12
  type TranscriptIdentity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null; taskRunId: string | null;
11
13
  agentOperationId: null; toolCallId: null };
@@ -87,6 +89,21 @@ function assistantFailureReason(message: any): string | null {
87
89
  if (/(?:network|fetch failed|econn|timed? ?out|unavailable)/.test(detail)) return "provider-unavailable";
88
90
  return "provider-response-failed";
89
91
  }
92
+ function assistantUnavailableReason(message: any, text: string, projectedToolCalls: TranscriptItem["toolCalls"]): string | null {
93
+ if (message?.role !== "assistant") return null;
94
+ const stopReason = String(message?.stopReason ?? "");
95
+ if (stopReason === "error") return assistantFailureReason(message) ?? "provider-response-failed";
96
+ if (stopReason === "aborted") return "assistant-message-aborted";
97
+ if (stopReason === "length") return "assistant-output-incomplete";
98
+ const gate = COMPLETION_GATE.exec(text.trimStart());
99
+ if (gate) return gate[1]?.toUpperCase() === "CONTINUING"
100
+ ? "assistant-completion-continuing" : "assistant-completion-not-approved";
101
+ if (projectedToolCalls.length > 0 || ["toolUse", "pending", "deferred"].includes(stopReason)) return "assistant-intermediate-output";
102
+ if (!stopReason) return "assistant-settlement-unknown";
103
+ if (stopReason && stopReason !== "stop") return "assistant-settlement-unknown";
104
+ if (!hasVisibleText(text)) return "assistant-message-empty";
105
+ return null;
106
+ }
90
107
  function safeText(value: string): { full: string; preview: string; redacted: boolean; truncated: boolean } {
91
108
  const clean = value.replace(ANSI, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
92
109
  const redaction = redactSensitiveText(clean);
@@ -129,17 +146,18 @@ function item(entry: any, identity: TranscriptIdentity): TranscriptItem | null {
129
146
  const itemRole = role(entry.message), recordedAt = timestamp(entry.timestamp ?? entry.message.timestamp);
130
147
  if (!itemRole || !recordedAt || typeof entry.id !== "string" || entry.id.length === 0) return null;
131
148
  const userProjection = userMessageProjection(entry.message);
149
+ const projectedToolCalls = toolCalls(entry.message, identity.sessionRef, itemRole);
132
150
  const content = itemRole === "tool-result" ? unavailableContent("tool-output-in-activity-preview") : (() => {
133
151
  const projected = safeText(itemRole === "user" ? userProjection.text : messageText(entry.message));
134
- const failureReason = projected.full.length === 0 ? assistantFailureReason(entry.message) : null;
135
- if (failureReason) return unavailableContent(failureReason);
152
+ const unavailableReason = assistantUnavailableReason(entry.message, projected.full, projectedToolCalls);
153
+ if (unavailableReason) return unavailableContent(unavailableReason);
136
154
  return { state: projected.redacted ? "redacted" as const : "available" as const, text: projected.preview,
137
155
  textChars: Math.min(1_000_000_000, projected.full.length), digest: `sha256:${hash(projected.full)}`, truncated: projected.truncated,
138
156
  redacted: projected.redacted, imageCount: imageCount(entry.message), reasonCode: projected.redacted ? "sensitive-values-redacted" as const : null };
139
157
  })();
140
158
  return { messageRef: opaque("message", [identity.sessionRef, entry.id]), parentMessageRef: null, role: itemRole, recordedAt,
141
159
  agentOperationId: null, turnIndex: null, content, ...(userProjection.attachments.length ? { attachments: userProjection.attachments } : {}),
142
- toolCalls: toolCalls(entry.message, identity.sessionRef, itemRole) };
160
+ toolCalls: projectedToolCalls };
143
161
  }
144
162
  function unavailable(input: TranscriptProjectionInput, reasonCode: string, limit: number): TranscriptDocument {
145
163
  return { schemaVersion: 1, version: "piagent-webui-transcript-v1", generatedAt: input.generatedAt ?? new Date().toISOString(),