oysterun 1.6.0-beta.2 → 1.6.0-beta.21

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 (242) hide show
  1. package/bin/oysterun.mjs +64 -17
  2. package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
  3. package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
  4. package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
  5. package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
  6. package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
  7. package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
  8. package/dev/client/connectivity/managed-host-browser.mjs +10 -0
  9. package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
  10. package/dev/client/connectivity/session-transition-state.mjs +18 -6
  11. package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
  12. package/dev/client/web/index.html +5367 -609
  13. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
  14. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
  15. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
  16. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
  17. package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
  18. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
  19. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
  21. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
  22. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
  23. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
  24. package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
  25. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
  26. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
  27. package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
  28. package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
  29. package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
  30. package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
  31. package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
  32. package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
  33. package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
  34. package/dev/client/web-chat/dist/index.html +1 -2
  35. package/docs/routec/dev/cloud_deplyments.md +5 -0
  36. package/docs/routec/dev/npm_native_dependency_install.md +6 -2
  37. package/docs/routec/dev/npmjs_publish.md +55 -19
  38. package/docs/routec/dev/p262_setup_input_file.md +117 -0
  39. package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
  40. package/docs/routec/host_setup.md +39 -10
  41. package/docs/routec/oysterun_operation_interface_spec.md +78 -2
  42. package/docs/routec/provider_contract.md +35 -5
  43. package/host-service/README.md +36 -0
  44. package/host-service/adapters/claude-code-adapter.mjs +48 -5
  45. package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
  46. package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
  47. package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
  48. package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
  49. package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
  50. package/host-service/agent-config.mjs +16 -5
  51. package/host-service/agent-folder-availability.mjs +41 -0
  52. package/host-service/apns-complete-message-dispatcher.mjs +1 -0
  53. package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
  54. package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
  55. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
  56. package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
  57. package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
  58. package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
  59. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
  60. package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
  61. package/host-service/callback-durable-store.mjs +354 -47
  62. package/host-service/callback-foundation.mjs +21 -1
  63. package/host-service/callback-host-integration.mjs +345 -30
  64. package/host-service/claude-acp-model-discovery.mjs +23 -18
  65. package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
  66. package/host-service/cli/installer-recovery-guidance.mjs +20 -0
  67. package/host-service/cli/prepare-restart-restore.mjs +2 -0
  68. package/host-service/cli/product-cli.mjs +34 -33
  69. package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
  70. package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
  71. package/host-service/cli/run-host-lifecycle.mjs +2 -0
  72. package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
  73. package/host-service/codex-model-discovery.mjs +23 -18
  74. package/host-service/complete-cli-uninstall.mjs +176 -0
  75. package/host-service/complete-message-notification-predicate.mjs +8 -6
  76. package/host-service/complete-message-notification-title.mjs +4 -0
  77. package/host-service/completed-session-unread.mjs +425 -42
  78. package/host-service/completed-turn-navigation.mjs +444 -144
  79. package/host-service/completed-turn-notification-observer.mjs +116 -0
  80. package/host-service/completed-turn-service.mjs +445 -0
  81. package/host-service/config.mjs +115 -9
  82. package/host-service/desired-open-session-state.mjs +331 -0
  83. package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
  84. package/host-service/frp-agent.mjs +9 -9
  85. package/host-service/grok-acp-model-discovery.mjs +31 -33
  86. package/host-service/host-lifecycle-recovery.mjs +37 -0
  87. package/host-service/host-port-resolution.mjs +98 -0
  88. package/host-service/host-runtime-diagnostics.mjs +18 -1
  89. package/host-service/initial-preflight-recovery.mjs +71 -0
  90. package/host-service/installer-recovery-guidance.mjs +85 -0
  91. package/host-service/installer-reinstall-bindings.mjs +49 -0
  92. package/host-service/installer-reinstall-target.mjs +49 -0
  93. package/host-service/legacy-continuity-observation.mjs +33 -0
  94. package/host-service/lifecycle-compat-cli.mjs +13 -0
  95. package/host-service/lifecycle-compat-preload.mjs +89 -0
  96. package/host-service/lifecycle-compatibility.mjs +164 -0
  97. package/host-service/lifecycle-continuity-assets.mjs +222 -0
  98. package/host-service/lifecycle-continuity-probe.mjs +97 -0
  99. package/host-service/lifecycle-continuity-projection.mjs +29 -0
  100. package/host-service/lifecycle-continuity-runner.mjs +192 -0
  101. package/host-service/lifecycle-continuity.mjs +493 -0
  102. package/host-service/lifecycle-service-render-bundle.mjs +120 -0
  103. package/host-service/lifecycle-user-path.mjs +34 -0
  104. package/host-service/link-annotations.mjs +10 -0
  105. package/host-service/matrix-credential-issuer.mjs +32 -1
  106. package/host-service/matrix-event-correlator.mjs +166 -27
  107. package/host-service/matrix-facade.mjs +96 -19
  108. package/host-service/matrix-room-binding.mjs +24 -0
  109. package/host-service/matrix-transcript-read-adapter.mjs +17 -3
  110. package/host-service/model-discovery-guardian.mjs +40 -0
  111. package/host-service/model-discovery-process.mjs +141 -0
  112. package/host-service/onboard-lifecycle-status.mjs +28 -0
  113. package/host-service/onboard.mjs +479 -0
  114. package/host-service/package.json +2 -1
  115. package/host-service/portable-scheduler-definitions.mjs +28 -0
  116. package/host-service/product-runtime-host-origin.mjs +22 -0
  117. package/host-service/provider-model-params.mjs +14 -17
  118. package/host-service/provider-model-refresh-runner.mjs +124 -169
  119. package/host-service/provider-model-refresh-schedule.mjs +11 -0
  120. package/host-service/provider-resume-contract.mjs +9 -3
  121. package/host-service/restart-restore-state.mjs +25 -0
  122. package/host-service/retained-host-state-holder.mjs +57 -0
  123. package/host-service/routec-matrix-sqlite-store.mjs +155 -10
  124. package/host-service/routec-matrix-storage-adapter.mjs +208 -25
  125. package/host-service/routec-matrix-storage-paths.mjs +12 -0
  126. package/host-service/routec-request-delay-window.mjs +55 -0
  127. package/host-service/scheduler-acp-provider-runner.mjs +2 -1
  128. package/host-service/scheduler-direct-provider-runner.mjs +71 -9
  129. package/host-service/scheduler-run-timeout-policy.mjs +85 -0
  130. package/host-service/scheduler-service.mjs +96 -9
  131. package/host-service/scheduler-store.mjs +9 -2
  132. package/host-service/server.mjs +2202 -552
  133. package/host-service/service-definition-migration.mjs +10 -3
  134. package/host-service/session-collection-service.mjs +320 -0
  135. package/host-service/session-history.mjs +99 -15
  136. package/host-service/session-list-preview-service.mjs +87 -12
  137. package/host-service/session-list-projection-service.mjs +182 -0
  138. package/host-service/session-loop-definitions.mjs +702 -75
  139. package/host-service/session-manager.mjs +473 -38
  140. package/host-service/session-switcher-projection.mjs +11 -3
  141. package/host-service/session-tags.mjs +451 -0
  142. package/host-service/session-transcript-target.mjs +103 -0
  143. package/host-service/setup-input-file.mjs +91 -0
  144. package/host-service/setup-provider-model-defaults.mjs +53 -0
  145. package/host-service/setup.mjs +133 -101
  146. package/host-service/stopped-user-host-package.mjs +60 -0
  147. package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
  148. package/host-service/update-reminder-state.mjs +7 -0
  149. package/host-service/voice-input-receipt-store.mjs +60 -0
  150. package/package.json +1 -1
  151. package/static/oysterun-social-preview.png +0 -0
  152. package/static/site/assets/oysterun-social-preview.png +0 -0
  153. package/static/site/blog/en/index.html +17 -0
  154. package/static/site/blog/generated_manifest.json +9 -9
  155. package/static/site/blog/index.html +18 -0
  156. package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
  157. package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
  158. package/static/site/blog/zh/index.html +17 -0
  159. package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
  160. package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
  161. package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
  162. package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
  163. package/static/site/docs/en/index.html +17 -0
  164. package/static/site/docs/generated_manifest.json +60 -60
  165. package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
  166. package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
  167. package/static/site/docs/index.html +18 -0
  168. package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
  169. package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
  170. package/static/site/docs/read-large-tool-results/en/index.html +17 -0
  171. package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
  172. package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
  173. package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
  174. package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
  175. package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
  176. package/static/site/docs/send-images-and-media/en/index.html +17 -0
  177. package/static/site/docs/send-images-and-media/zh/index.html +17 -0
  178. package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
  179. package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
  180. package/static/site/docs/understand-agent-activity/en/index.html +19 -2
  181. package/static/site/docs/understand-agent-activity/en/index.md +2 -2
  182. package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
  183. package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
  184. package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
  185. package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
  186. package/static/site/docs/zh/index.html +17 -0
  187. package/static/site/en/index.html +18 -1
  188. package/static/site/en/index.md +1 -1
  189. package/static/site/faq/en/index.html +17 -0
  190. package/static/site/faq/index.html +18 -0
  191. package/static/site/faq/zh/index.html +17 -0
  192. package/static/site/getting-started/en/index.html +23 -1
  193. package/static/site/getting-started/en/index.md +7 -1
  194. package/static/site/getting-started/index.html +18 -0
  195. package/static/site/getting-started/zh/index.html +23 -1
  196. package/static/site/getting-started/zh/index.md +7 -1
  197. package/static/site/index.html +18 -0
  198. package/static/site/install.sh +676 -127
  199. package/static/site/overview/en/index.html +18 -1
  200. package/static/site/overview/en/index.md +2 -2
  201. package/static/site/overview/index.html +18 -0
  202. package/static/site/overview/zh/index.html +18 -1
  203. package/static/site/overview/zh/index.md +2 -2
  204. package/static/site/privacy/en/index.html +17 -0
  205. package/static/site/privacy/index.html +18 -0
  206. package/static/site/privacy/zh/index.html +17 -0
  207. package/static/site/zh/index.html +18 -1
  208. package/static/site/zh/index.md +1 -1
  209. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
  210. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
  211. package/static/site_source/downloads/install.sh +676 -127
  212. package/static/site_source/manifests/host_install.json +2 -10
  213. package/static/site_source/pages/getting-started/en.html +6 -1
  214. package/static/site_source/pages/getting-started/zh.html +6 -1
  215. package/static/site_source/pages/home/en.html +1 -1
  216. package/static/site_source/pages/home/zh.html +1 -1
  217. package/static/site_source/pages/home_main.html +3 -2
  218. package/static/site_source/pages/overview/en.html +1 -1
  219. package/static/site_source/pages/overview/zh.html +1 -1
  220. package/tool_scripts/install_oysterun_update.mjs +78 -13
  221. package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
  222. package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
  223. package/tool_scripts/oysterun_release_service.sh +5 -0
  224. package/tool_scripts/oysterun_service_common.sh +59 -24
  225. package/tool_scripts/oysterun_setup_input.sh +97 -0
  226. package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
  227. package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
  228. package/tool_scripts/resolve_user_host_port.mjs +15 -0
  229. package/tool_scripts/restart_oysterun.sh +8 -2
  230. package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
  231. package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
  232. package/tool_scripts/sync_host_stack_config.mjs +9 -6
  233. package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
  234. package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
  235. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
  236. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
  237. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
  238. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
  239. package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
  240. package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
  241. package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
  242. package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
@@ -1,3 +1,21 @@
1
+ import { VoiceInputReceiptStore, voiceInputFingerprint } from './voice-input-receipt-store.mjs';
2
+ import { readAgentFolderAvailability, requireAgentFolder, agentFolderErrorResponse } from "./agent-folder-availability.mjs";
3
+ import { resolveSessionTranscriptTarget, sessionTranscriptTargetFields, createHistoryTranscriptPager } from "./session-transcript-target.mjs";
4
+ import { withRouteCMatrixTranscriptReadOnly } from "./routec-matrix-storage-adapter.mjs";
5
+ import { getServiceDefinitionIdentity, buildServiceRenderBundle } from "./lifecycle-service-render-bundle.mjs";
6
+ import { parseHostPort, readUserHostPort } from "./host-port-resolution.mjs";
7
+ import {
8
+ assertConfirmedUpdateTarget, assertFreshContinuitySnapshot, continuityDigest, continuityFailure, recordContinuityRecovery,
9
+ continuityReceiptPath, prepareContinuity, readContinuity, bindFreshContinuityBaseline, writeContinuityAtomic,
10
+ hasCompleteContinuityResumeIdentity, compareContinuityIdentities,
11
+ } from "./lifecycle-continuity.mjs";
12
+ import { recordContinuityUserPath } from "./lifecycle-user-path.mjs";
13
+ import { projectContinuityOperation } from "./lifecycle-continuity-projection.mjs";
14
+ import { resolveContinuityQualificationOrigin } from "./lifecycle-continuity-runner.mjs";
15
+ import { retainContinuityFile, digestContinuityPackage } from "./lifecycle-continuity-assets.mjs";
16
+ import {
17
+ createContinuityProbeGrant, verifyContinuityProbeGrant,
18
+ } from "./lifecycle-continuity-probe.mjs";
1
19
  import { spawn, spawnSync } from "child_process";
2
20
  import { createRequire } from "module";
3
21
  import { createServer } from "http";
@@ -24,6 +42,7 @@ import { performance } from "perf_hooks";
24
42
  import { brotliCompressSync, gzipSync } from "zlib";
25
43
  import { homedir } from "os";
26
44
  import { WebSocketServer } from "ws";
45
+ import { createRouteCRequestDelayWindowController } from "./routec-request-delay-window.mjs";
27
46
  import { SessionManager } from "./session-manager.mjs";
28
47
  import {
29
48
  DEFAULT_HOST_PRODUCT_CAPABILITIES,
@@ -48,12 +67,14 @@ import {
48
67
  } from "./scheduler-run-product-contract.mjs";
49
68
  import { finalizeSchedulerMutationResponse } from "./scheduler-mutation-audit.mjs";
50
69
  import { buildSessionLifecycleRevision } from "./session-lifecycle-snapshot.mjs";
70
+ import { resolveProductRuntimeHostOrigin } from "./product-runtime-host-origin.mjs";
51
71
  import { createTunnelAgent } from "./tunnel-agent-factory.mjs";
52
72
  import { buildWebsiteCanonicalUrlPayload } from "./website-canonical-url.mjs";
53
73
  import {
54
74
  buildInitialSharedAgentConfig,
55
75
  getConfigDir,
56
76
  getConfigPath,
77
+ readRawConfigSource,
57
78
  getConfigSource,
58
79
  getDefaultConfig,
59
80
  getDefaultHostDisplayName,
@@ -168,7 +189,20 @@ import {
168
189
  logProviderStartupEvent,
169
190
  preflightProviderStartup,
170
191
  } from "./provider-startup-preflight.mjs";
171
- import { getSessionHistory } from "./session-history.mjs";
192
+ import {
193
+ getSessionHistory,
194
+ updateSessionRecord,
195
+ captureSessionTagsHistoryRollback,
196
+ } from "./session-history.mjs";
197
+ import {
198
+ RecentSessionTagsStore,
199
+ SessionTagLabelsStore,
200
+ commitSessionTagSave,
201
+ resolveSessionStartTags,
202
+ canonicalSessionTags,
203
+ projectSessionTags,
204
+ normalizeSessionTags,
205
+ } from "./session-tags.mjs";
172
206
  import {
173
207
  buildDefaultBranchName,
174
208
  buildSessionNameConflictMessage,
@@ -212,6 +246,7 @@ import {
212
246
  isLoopbackRemoteAddress,
213
247
  verifyLocalServiceControlToken,
214
248
  } from "./service-control-token.mjs";
249
+ import { reconcileFailedInitialPreflight, failedInitialPreflightState } from "./initial-preflight-recovery.mjs";
215
250
  import {
216
251
  UPDATE_RESTART_RUNNER_LEASE_MS,
217
252
  evaluateCompletedRestartRecovery,
@@ -241,6 +276,7 @@ import {
241
276
  } from "./host-restart-login-gate.mjs";
242
277
  import {
243
278
  appendHostRuntimeDiagnostic,
279
+ getHostRuntimeMetadataPath,
244
280
  readHostRuntimeMetadataStatus,
245
281
  serializeRuntimeError,
246
282
  writeHostRuntimeMetadata,
@@ -293,12 +329,20 @@ import { TransportUsageCircuitStore } from "./connectivity/transport-usage-circu
293
329
  import { createRouteCMatrixFacade } from "./matrix-facade.mjs";
294
330
  import {
295
331
  openRouteCMatrixSQLiteStore,
332
+ readRouteCMatrixSQLiteEvent,
333
+ readRouteCMatrixSQLiteLatestTimelineSeqs,
334
+ withRouteCMatrixSQLiteRoomReadContext,
296
335
  } from "./routec-matrix-sqlite-store.mjs";
297
336
  import { openDurableCallbackStore } from "./callback-durable-store.mjs";
298
337
  import { createCallbackHostIntegration } from "./callback-host-integration.mjs";
338
+ import {
339
+ createCompleteMessageNotificationBroadcaster,
340
+ observeCommittedCompletedTurn,
341
+ } from "./completed-turn-notification-observer.mjs";
299
342
  import {
300
343
  createRouteCMatrixRoomBinding,
301
344
  getRouteCMatrixRoomBinding,
345
+ getRouteCMatrixRoomBindings,
302
346
  requireRouteCMatrixRoomBinding,
303
347
  } from "./matrix-room-binding.mjs";
304
348
  import {
@@ -309,12 +353,19 @@ import {
309
353
  readMatrixTranscriptRecentTail,
310
354
  } from "./matrix-transcript-read-adapter.mjs";
311
355
  import { SessionListPreviewService } from "./session-list-preview-service.mjs";
312
- import { buildSessionSwitcherRows } from "./session-switcher-projection.mjs";
356
+ import { SessionListProjectionService } from "./session-list-projection-service.mjs";
357
+ import {
358
+ admitSessionCollectionLiveSession,
359
+ SessionCollectionService,
360
+ isSessionCollectionLiveSessionAdmitted,
361
+ } from "./session-collection-service.mjs";
313
362
  import {
314
363
  HOST_OWNER_READ_CURSOR_USER_ID,
315
364
  acknowledgeCompletedSessionRead,
365
+ beginCompletedSessionChatVisit,
316
366
  ensureCompletedSessionUnreadSchema,
317
- readCompletedSessionUnreadProjection,
367
+ createCompletedSessionUnreadReader,
368
+ markLatestCompletedSessionUnread,
318
369
  } from "./completed-session-unread.mjs";
319
370
  import { buildLiveSessionProfilePayload } from "./session-profile-runtime.mjs";
320
371
  import {
@@ -333,6 +384,7 @@ import {
333
384
  import {
334
385
  cancelRouteCHost2IntakeForMatrixEvent,
335
386
  getRouteCHost2IntakeProof,
387
+ restoreRouteCHost2IntakeFromCommittedMatrixEvent,
336
388
  } from "./matrix-event-correlator.mjs";
337
389
  import {
338
390
  buildDemotedLoopStopCommandResponse,
@@ -521,14 +573,25 @@ try {
521
573
  // ── Config (env vars take precedence over ~/.oysterun/config.json) ──
522
574
 
523
575
  const DEFAULT_HOST_CONFIG = getDefaultConfig();
576
+ const RESOLVED_HOST_PORT = process.env.OYSTERUN_PORT !== undefined
577
+ ? parseHostPort(process.env.OYSTERUN_PORT)
578
+ : getActiveStackName() && getActiveStackName() !== "production"
579
+ ? parseHostPort(readRawConfigSource().port ?? DEFAULT_HOST_CONFIG.port)
580
+ : readUserHostPort({
581
+ configPath: getConfigPath(), runtimePath: getHostRuntimeMetadataPath(), configDir: getConfigDir(),
582
+ existingServicePaths: [join(getConfigDir(), "run", "oysterun-host.pid"),
583
+ join(getConfigDir(), "run", "oysterun-host.origin.tsv"),
584
+ join(homedir(), "Library", "LaunchAgents", "com.oysterun.host.plist")],
585
+ });
586
+ if (RESOLVED_HOST_PORT === null) throw new Error("Invalid Host port; use an integer from 1 to 65535.");
587
+ if (process.env.OYSTERUN_PORT === undefined && existsSync(getConfigPath()) &&
588
+ !Object.hasOwn(readRawConfigSource(), "port")) writeConfig({ port: RESOLVED_HOST_PORT });
524
589
  const CONNECTION_MODE =
525
590
  getConfigValue("connection_mode", "OYSTERUN_CONNECTION_MODE") === "cloud"
526
591
  ? "cloud"
527
592
  : "direct";
528
- const PORT = parseInt(
529
- getConfigValue("port", "OYSTERUN_PORT") || String(DEFAULT_HOST_CONFIG.port),
530
- 10
531
- );
593
+ const PORT = RESOLVED_HOST_PORT;
594
+ if (PORT === null) throw new Error("Invalid Host port; use an integer from 1 to 65535.");
532
595
  let localServiceControlReady = false;
533
596
  try {
534
597
  ensureLocalServiceControlToken({ configDir: getConfigDir() });
@@ -1010,23 +1073,6 @@ function isRouteCMatrixBindingNotFoundError(err) {
1010
1073
  );
1011
1074
  }
1012
1075
 
1013
- function getLastMatrixMessagePreviewForBinding(binding) {
1014
- return sessionListPreviewService.readForBinding(binding).last_message_preview;
1015
- }
1016
-
1017
- function getLastMessagePreview(agentFolder, agentId, sessionId) {
1018
- const binding = getRouteCMatrixRoomBinding(sessionId);
1019
- if (binding) {
1020
- if (agentId && binding.host_agent_id !== agentId) return null;
1021
- try {
1022
- return getLastMatrixMessagePreviewForBinding(binding);
1023
- } catch {
1024
- return null;
1025
- }
1026
- }
1027
- return null;
1028
- }
1029
-
1030
1076
  function shouldIncludeSessionListPreviews(url) {
1031
1077
  const raw = normalizeString(url.searchParams.get("include_previews"));
1032
1078
  return raw !== "0" && raw !== "false" && raw !== "no";
@@ -1562,6 +1608,39 @@ function isCleanSessionChatAppShellPath(requestPath) {
1562
1608
  return /^\/app\/sessions\/[^/]+\/chat\/?$/.test(requestPath);
1563
1609
  }
1564
1610
 
1611
+ function isCleanSessionMigratedSurfaceAppShellPath(requestPath) {
1612
+ return /^\/app\/sessions\/[^/]+\/(?:profile|loop|explorer|file-preview)\/?$/.test(requestPath);
1613
+ }
1614
+
1615
+ function buildLegacySessionSurfaceRedirectLocation(requestUrl) {
1616
+ if (!(requestUrl instanceof URL)) return null;
1617
+ const surface =
1618
+ requestUrl.pathname === "/app/agent-profile"
1619
+ ? "profile"
1620
+ : requestUrl.pathname === "/app/session-loop"
1621
+ ? "loop"
1622
+ : null;
1623
+ if (!surface) return null;
1624
+ const sessionId = normalizeString(requestUrl.searchParams.get("session_id"));
1625
+ if (!sessionId) return null;
1626
+ return `/app/sessions/${encodeURIComponent(sessionId)}/${surface}${requestUrl.search}`;
1627
+ }
1628
+
1629
+ function isStandaloneMigratedSurfaceAppShellPath(requestPath) {
1630
+ return /^\/app\/(?:file-preview|web|browser|mail(?:\/[^/]+)?)\/?$/.test(requestPath);
1631
+ }
1632
+
1633
+ function isStandaloneMigratedBrowseAppShellPath(requestPath) {
1634
+ return requestPath === "/app/explorer" ||
1635
+ requestPath === "/app/explorer/" ||
1636
+ requestPath === "/app/folder-picker" ||
1637
+ requestPath === "/app/folder-picker/" ||
1638
+ requestPath === "/app/change-password" ||
1639
+ requestPath === "/app/change-password/" ||
1640
+ requestPath === "/app/tool-detail" ||
1641
+ requestPath === "/app/tool-detail/";
1642
+ }
1643
+
1565
1644
  function getCleanSessionChatAppShellSessionId(requestPath) {
1566
1645
  const match =
1567
1646
  typeof requestPath === "string"
@@ -1575,6 +1654,23 @@ function getCleanSessionChatAppShellSessionId(requestPath) {
1575
1654
  }
1576
1655
  }
1577
1656
 
1657
+ function getCleanSessionAppShellSessionId(requestPath) {
1658
+ const chatSessionId = getCleanSessionChatAppShellSessionId(requestPath);
1659
+ if (chatSessionId) return chatSessionId;
1660
+ const match =
1661
+ typeof requestPath === "string"
1662
+ ? requestPath.match(
1663
+ /^\/app\/sessions\/([^/]+)\/(?:profile|loop|explorer|file-preview)\/?$/,
1664
+ )
1665
+ : null;
1666
+ if (!match) return null;
1667
+ try {
1668
+ return decodeURIComponent(match[1]);
1669
+ } catch {
1670
+ return match[1];
1671
+ }
1672
+ }
1673
+
1578
1674
  function buildStaleCleanSessionChatRedirectLocation() {
1579
1675
  return "/app/sessions?notice=stale_clean_chat";
1580
1676
  }
@@ -1634,7 +1730,11 @@ function serveWebChatAppDocument(req, res) {
1634
1730
 
1635
1731
  const response = buildRouteCStaticResponse(
1636
1732
  req,
1637
- readFileSync(ROUTEC_WEB_CHAT_INDEX_PATH),
1733
+ readFileSync(ROUTEC_WEB_CHAT_INDEX_PATH, "utf8").replace("</head>",
1734
+ `<script id="host-default-session-defaults-json" type="application/json">${JSON.stringify(getDefaultSessionDefaults()).replace(/</g, "\\u003c")}</script>` +
1735
+ `<script id="host-default-shared-allowed-paths-json" type="application/json">${JSON.stringify(getDefaultSharedAllowedPaths()).replace(/</g, "\\u003c")}</script>` +
1736
+ `<script id="host-routec-auth-loss-diagnostics-json" type="application/json">${JSON.stringify(readConfig().debug_routec_auth_loss_diagnostics_enabled === true)}</script>` +
1737
+ `<script id="host-system-timezone-json" type="application/json">${JSON.stringify(getHostSystemTimezone()).replace(/</g, "\\u003c")}</script></head>`),
1638
1738
  getWebChatAppAssetHeaders(ROUTEC_WEB_CHAT_INDEX_PATH),
1639
1739
  { assetPath: ROUTEC_WEB_CHAT_INDEX_PATH }
1640
1740
  );
@@ -1804,6 +1904,7 @@ function getDashboardSessionRecord(token) {
1804
1904
  function dashboardAuthenticate(req) {
1805
1905
  const token = getDashboardSessionToken(req);
1806
1906
  if (!token) return null;
1907
+ recordContinuityAuthenticatedUserPath(req);
1807
1908
  return {
1808
1909
  user_id: "dashboard-admin",
1809
1910
  device_id: DEVICE_ID || "local",
@@ -2486,6 +2587,7 @@ async function dispatchHostScheduleThroughTransientAcp(
2486
2587
  messageId,
2487
2588
  env: preparedRuntimeWorkspace.normalizedRuntimeEnv,
2488
2589
  redactionValues: mailCapability?.redactionValues || [],
2590
+ timeoutMs: schedule.run_timeout_seconds * 1000,
2489
2591
  });
2490
2592
  return {
2491
2593
  ...result,
@@ -2526,6 +2628,7 @@ function createSchedulerSessionDispatcher(
2526
2628
  messageId,
2527
2629
  env: mailCapability?.env || {},
2528
2630
  redactionValues: mailCapability?.redactionValues || [],
2631
+ timeoutMs: schedule.run_timeout_seconds * 1000,
2529
2632
  });
2530
2633
  return {
2531
2634
  ...result,
@@ -3015,6 +3118,9 @@ function buildHostSchedulerPayloadFromBody(body) {
3015
3118
  rule: buildHostSchedulerRuleFromBody(body),
3016
3119
  timezone: normalizeString(body.timezone),
3017
3120
  enabled: body.enabled === true,
3121
+ runTimeoutSeconds: hasBodyField(body, "run_timeout_seconds")
3122
+ ? body.run_timeout_seconds
3123
+ : undefined,
3018
3124
  };
3019
3125
  }
3020
3126
 
@@ -3045,6 +3151,9 @@ function buildHostSchedulerPatchPayloadFromBody(body) {
3045
3151
  if (hasBodyField(body, "enabled")) {
3046
3152
  payload.enabled = body.enabled === true;
3047
3153
  }
3154
+ if (hasBodyField(body, "run_timeout_seconds")) {
3155
+ payload.runTimeoutSeconds = body.run_timeout_seconds;
3156
+ }
3048
3157
  return payload;
3049
3158
  }
3050
3159
 
@@ -3053,16 +3162,11 @@ function getLoopbackHostOrigin() {
3053
3162
  }
3054
3163
 
3055
3164
  function getProductRuntimeHostOrigin() {
3056
- const activeDirectHostUrl = normalizeString(DIRECT_HOST_URL);
3057
- if (activeDirectHostUrl) return activeDirectHostUrl.replace(/\/+$/, "");
3058
- const config = readConfig();
3059
- const configuredDirectHostUrl = normalizeString(
3060
- config.direct_host_url || config.public_base_url
3061
- );
3062
- if (configuredDirectHostUrl) {
3063
- return configuredDirectHostUrl.replace(/\/+$/, "");
3064
- }
3065
- return getLoopbackHostOrigin();
3165
+ return resolveProductRuntimeHostOrigin({
3166
+ config: readConfig(),
3167
+ directHostUrl: DIRECT_HOST_URL,
3168
+ port: PORT,
3169
+ });
3066
3170
  }
3067
3171
 
3068
3172
  function getSchedulerMailHostOrigin() {
@@ -3511,6 +3615,7 @@ function resolveSchedulerTargetToDirectProviderRun({ targetBinding }) {
3511
3615
  }
3512
3616
 
3513
3617
  const sessionManager = new SessionManager();
3618
+ const voiceInputReceipts = new VoiceInputReceiptStore();
3514
3619
  const voiceInputSingleFlight = new VoiceInputSingleFlight();
3515
3620
  const voiceInputUsageStore = new VoiceInputUsageStore({
3516
3621
  configDir: getConfigDir(),
@@ -3520,7 +3625,153 @@ const voiceInputRecordingCollector = new VoiceInputRecordingCollector({
3520
3625
  isOwnerCaptureEnabled: () =>
3521
3626
  readVoiceInputPrivateSettings(getConfigDir()).owner_capture_enabled === true,
3522
3627
  });
3523
- const sessionListPreviewService = new SessionListPreviewService();
3628
+ let sessionListProjectionReadState = null;
3629
+ function getSessionListProjectionReadState() {
3630
+ if (sessionListProjectionReadState) return sessionListProjectionReadState;
3631
+ const db = openRouteCMatrixSQLiteStore({
3632
+ jsonStoragePath: getRouteCMatrixStoragePath(),
3633
+ });
3634
+ const unreadReader = createCompletedSessionUnreadReader(db, {
3635
+ hostUserId: HOST_OWNER_READ_CURSOR_USER_ID,
3636
+ });
3637
+ sessionListProjectionReadState = { db, unreadReader };
3638
+ return sessionListProjectionReadState;
3639
+ }
3640
+ const sessionListPreviewService = new SessionListPreviewService({
3641
+ readCheckpoints: ({ bindings }) => {
3642
+ const { db } = getSessionListProjectionReadState();
3643
+ return readRouteCMatrixSQLiteLatestTimelineSeqs(db, {
3644
+ roomIds: bindings.map((binding) => binding.matrix_room_id),
3645
+ });
3646
+ },
3647
+ });
3648
+ const sessionListProjectionService = new SessionListProjectionService({
3649
+ readTagLabels: currentSessionTagLabels,
3650
+ readBindings: getRouteCMatrixRoomBindings,
3651
+ previewService: sessionListPreviewService,
3652
+ prepareDecorationRead: () => {
3653
+ const { db, unreadReader } = getSessionListProjectionReadState();
3654
+ return {
3655
+ schemaVersion: unreadReader.schema_version,
3656
+ readUnread: (binding, readContext) =>
3657
+ unreadReader.read(binding, readContext),
3658
+ withRoomRead: (roomId, read) => withRouteCMatrixSQLiteRoomReadContext(db, { roomId }, read),
3659
+ };
3660
+ },
3661
+ });
3662
+
3663
+ const sessionCollectionAdmittedSessionIds = new Set();
3664
+
3665
+ function buildCanonicalSessionCollectionRows() {
3666
+ const allLiveSessions = sessionManager.list();
3667
+ const desiredRestore = sessionManager.getDesiredOpenRestoreStatus();
3668
+ const failedRestoreBySessionId = new Map(
3669
+ desiredRestore.sessions
3670
+ .filter((entry) => entry.state === "failed")
3671
+ .map((entry) => [entry.session_id, entry])
3672
+ );
3673
+ const liveSessions = allLiveSessions.filter((session) => {
3674
+ const sessionId = session.id || session.sessionId;
3675
+ return (
3676
+ !failedRestoreBySessionId.has(sessionId) &&
3677
+ sessionCollectionAdmittedSessionIds.has(sessionId) &&
3678
+ isSessionCollectionLiveSessionAdmitted(
3679
+ sessionManager.getSession(sessionId),
3680
+ getRouteCMatrixRoomBinding(sessionId)
3681
+ )
3682
+ );
3683
+ });
3684
+ const liveSessionIds = new Set(
3685
+ liveSessions.map((session) => session.id || session.sessionId)
3686
+ );
3687
+ const liveRows = sessionListProjectionService
3688
+ .metadata(liveSessions)
3689
+ .session_rows.map((row) => {
3690
+ const pendingControlRequests = serializePendingControlRequests(
3691
+ sessionManager.getSession(row.session_id)
3692
+ );
3693
+ const providerLifecycle = sessionManager.getRouteCProviderLifecycle(
3694
+ row.session_id,
3695
+ { pendingControlRequests }
3696
+ );
3697
+ return {
3698
+ ...row,
3699
+ provider_lifecycle_active: providerLifecycle.active === true,
3700
+ restore_failed: false,
3701
+ restore_failed_reason: null,
3702
+ manual_start_supported: false,
3703
+ manual_start_setup_reason: null,
3704
+ };
3705
+ });
3706
+ const historyRows = [];
3707
+ const historyRecords = [...getSessionHistory()];
3708
+ const historyRecordBySessionId = new Map(
3709
+ historyRecords.map((record) => [record.session_id, record])
3710
+ );
3711
+ for (const failed of failedRestoreBySessionId.values()) {
3712
+ if (historyRecordBySessionId.has(failed.session_id)) continue;
3713
+ historyRecords.push({
3714
+ session_id: failed.session_id,
3715
+ session_name: failed.session_name || failed.session_id,
3716
+ agent_id: failed.agent_id,
3717
+ agent_folder: failed.agent_folder || null,
3718
+ runtime: failed.provider || null,
3719
+ model: failed.model || null,
3720
+ created_at: failed.started_at || null,
3721
+ last_active_at: failed.last_active_at || failed.started_at || null,
3722
+ tags: [],
3723
+ });
3724
+ }
3725
+ for (const record of historyRecords) {
3726
+ if (!record.session_id || liveSessionIds.has(record.session_id)) continue;
3727
+ const restoreFailure =
3728
+ failedRestoreBySessionId.get(record.session_id) ||
3729
+ (record.restore_failed_reason
3730
+ ? {
3731
+ session_id: record.session_id,
3732
+ state: "failed",
3733
+ reason: record.restore_failed_reason,
3734
+ }
3735
+ : null);
3736
+ const folderAvailable = Boolean(
3737
+ record.agent_folder && existsSync(record.agent_folder)
3738
+ );
3739
+ // Retained History membership is lifecycle-owned, even when its folder is gone.
3740
+ if (!record.agent_id) continue;
3741
+ const binding = getRouteCMatrixRoomBinding(record.session_id);
3742
+ const disposition = buildHistoryResumeDisposition(record, Boolean(binding));
3743
+ historyRows.push({
3744
+ kind: "history",
3745
+ session_id: record.session_id,
3746
+ agent_id: record.agent_id,
3747
+ session_name: record.session_name || record.session_id,
3748
+ ...projectSessionTags(record),
3749
+ agent_folder: record.agent_folder || null,
3750
+ runtime: record.runtime || null,
3751
+ model: record.model || null,
3752
+ started_at: record.created_at || null,
3753
+ last_active_at: record.last_active_at || record.created_at || null,
3754
+ running: false,
3755
+ resume_supported: disposition.resume_supported,
3756
+ resume_unavailable_reason: disposition.resume_unavailable_reason,
3757
+ restore_failed: Boolean(restoreFailure),
3758
+ restore_failed_reason: restoreFailure?.reason || null,
3759
+ manual_start_supported:
3760
+ Boolean(restoreFailure) && folderAvailable && disposition.resume_supported,
3761
+ manual_start_setup_reason: restoreFailure
3762
+ ? folderAvailable
3763
+ ? disposition.resume_unavailable_reason
3764
+ : "agent_folder_missing"
3765
+ : null,
3766
+ });
3767
+ }
3768
+ return [...liveRows, ...historyRows];
3769
+ }
3770
+
3771
+ const sessionCollectionService = new SessionCollectionService({
3772
+ readRows: buildCanonicalSessionCollectionRows,
3773
+ retention: 128,
3774
+ });
3524
3775
  const browserRootProjectIndex = new BrowserRootProjectIndex({ logger: console });
3525
3776
  setTranscriptSiteProjectFolderResolver((projectId) =>
3526
3777
  browserRootProjectIndex.resolveProjectFolder(projectId, { allowInvalid: true })
@@ -3548,6 +3799,10 @@ const toolEventDetailStore = createToolEventDetailStore({
3548
3799
  });
3549
3800
  const schedulerService = new SchedulerService({
3550
3801
  mailStore,
3802
+ sessionLoopUnavailableGraceMs:
3803
+ process.env.OYSTERUN_SESSION_LOOP_DEFINITION_UNAVAILABLE_GRACE_MS === undefined
3804
+ ? undefined
3805
+ : Number(process.env.OYSTERUN_SESSION_LOOP_DEFINITION_UNAVAILABLE_GRACE_MS),
3551
3806
  getHostOrigin: getSchedulerMailHostOrigin,
3552
3807
  getBrowserRootProjects: listBrowserRootSchedulerProjects,
3553
3808
  getBrowserRootMutationProjects: listBrowserRootSchedulerMutationProjects,
@@ -3589,31 +3844,17 @@ routeCMatrixFacade = createRouteCMatrixFacade({
3589
3844
  onCommittedMatrixEvent: async (matrixEvent) => {
3590
3845
  sessionListPreviewService.invalidateRoom(matrixEvent?.room_id);
3591
3846
  voiceInputRecordingCollector.observeCommittedMatrixEvent(matrixEvent);
3592
- const result =
3593
- await apnsCompleteMessageDispatcher.dispatchCommittedMatrixEvent(matrixEvent);
3594
- if (result?.notificationReleased === true && result?.candidate) {
3595
- broadcastCompleteMessageNotificationCandidate(result.candidate);
3596
- }
3597
- const targetSessionId =
3598
- matrixEvent?.content?.["org.oysterun.semantic.v1"]?.host_session_id ||
3599
- matrixEvent?.host_session_id ||
3600
- null;
3601
- if (callbackHostIntegration && targetSessionId) {
3602
- const semantic = matrixEvent?.content?.["org.oysterun.semantic.v1"] || {};
3603
- const terminalCompletion =
3604
- semantic.provider_completion_marker ===
3605
- "oysterun_provider_complete_message_notification_v1" &&
3606
- semantic.provider_completion_state !== "pending_turn_completion";
3607
- setImmediate(async () => {
3608
- try {
3609
- await callbackHostIntegration.observePendingForTarget(targetSessionId, {
3610
- finalizeSuccessfulWithoutMessage: terminalCompletion,
3611
- });
3612
- } catch (err) {
3613
- console.warn(`[p203-callback] committed truth observation failed: ${err.message || err}`);
3614
- }
3615
- });
3616
- }
3847
+ observeCommittedCompletedTurn({
3848
+ matrixEvent,
3849
+ matrixStore: STARTUP_MATRIX_STORAGE,
3850
+ getSession: (sessionId) => sessionManager.getSession(sessionId),
3851
+ broadcastCandidate: broadcastCompleteMessageNotificationCandidate,
3852
+ dispatchCandidate: (candidate, options) =>
3853
+ apnsCompleteMessageDispatcher
3854
+ .dispatchNotifiableProviderOutputNotificationCandidate(candidate, options),
3855
+ callbackHostIntegration,
3856
+ recordProjectionDiagnostic: recordCompletedTurnNotificationProjectionDiagnostic,
3857
+ });
3617
3858
  },
3618
3859
  });
3619
3860
  // Matrix storage must be bootstrapped before the first consumer opens it. Keep
@@ -3659,6 +3900,30 @@ callbackHostIntegration = createCallbackHostIntegration({
3659
3900
  serverEventId,
3660
3901
  }) => {
3661
3902
  const binding = requireRouteCMatrixRoomBinding(session.id);
3903
+ if (
3904
+ deliveryKind === "callback_result" &&
3905
+ !getRouteCHost2IntakeProof({
3906
+ hostSessionId: session.id,
3907
+ matrixRoomId: binding.matrix_room_id,
3908
+ serverEventId,
3909
+ })
3910
+ ) {
3911
+ const committed = readRouteCMatrixSQLiteEvent(STARTUP_MATRIX_STORAGE, {
3912
+ roomId: binding.matrix_room_id,
3913
+ eventId: serverEventId,
3914
+ });
3915
+ if (!committed) {
3916
+ throw new Error("P238 callback provider recovery requires committed Matrix truth");
3917
+ }
3918
+ restoreRouteCHost2IntakeFromCommittedMatrixEvent({
3919
+ hostSessionId: session.id,
3920
+ matrixUserId: committed.sender,
3921
+ matrixRoomId: binding.matrix_room_id,
3922
+ txnId: messageId,
3923
+ serverEventId,
3924
+ content: committed.content,
3925
+ });
3926
+ }
3662
3927
  return sessionManager.deliverRouteCMatrixUserEventToProvider({
3663
3928
  sessionId: session.id,
3664
3929
  matrixUserId: binding.matrix_user_id,
@@ -3674,6 +3939,12 @@ callbackHostIntegration = createCallbackHostIntegration({
3674
3939
  },
3675
3940
  cancelProviderDelivery: ({ sessionId, messageId }) =>
3676
3941
  sessionManager.cancelOutboxMessage(sessionId, messageId),
3942
+ reconcileProviderCancellation: ({ sessionId, messageId, providerText }) =>
3943
+ sessionManager.reconcileProviderMessageCancellation(sessionId, {
3944
+ messageId,
3945
+ text: providerText,
3946
+ reconcileProviderAcceptance: true,
3947
+ }),
3677
3948
  });
3678
3949
  const schedulerSessionDispatcher = createSchedulerSessionDispatcher(
3679
3950
  sessionManager,
@@ -3700,6 +3971,18 @@ const providerAuthManager = new ProviderAuthManager({
3700
3971
  });
3701
3972
  const terminalSessionManager = new TerminalSessionManager();
3702
3973
  let lastHostRestartRestoreStatus = null;
3974
+ let lastDesiredOpenRestoreStatus = null;
3975
+ let desiredOpenReconciliationPromise = null;
3976
+ const DESIRED_OPEN_RESTORE_MAX_CONCURRENCY = 4;
3977
+ const DESIRED_OPEN_RESTORE_DEFAULT_SESSION_TIMEOUT_MS = 30_000;
3978
+ const DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS = (() => {
3979
+ const injected = Number(
3980
+ process.env.OYSTERUN_DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS
3981
+ );
3982
+ return isBoundaryTestRuntime() && Number.isInteger(injected) && injected >= 0
3983
+ ? injected
3984
+ : DESIRED_OPEN_RESTORE_DEFAULT_SESSION_TIMEOUT_MS;
3985
+ })();
3703
3986
  const telegramRealAdapter = createTelegramBotApiAdapter();
3704
3987
  const telegramMediaTransactionStore = new TelegramMediaTransactionStore();
3705
3988
  const telegramMockDebugGateClosedAdapter = {
@@ -3845,28 +4128,81 @@ let hostTelemetryScheduler = null;
3845
4128
  */
3846
4129
  const sessionSubscribers = new Map();
3847
4130
  const completeMessageNotificationSubscribers = new Set();
4131
+ const completedTurnNotificationProjectionDiagnosticKeys = new Set();
3848
4132
  const mailNotificationSubscribers = new Set();
4133
+ const sessionCollectionSubscribers = new Set();
3849
4134
 
3850
- function broadcastCompleteMessageNotificationCandidate(candidate) {
3851
- if (!candidate || candidate.source !== "matrix_committed_event") return;
3852
- if (!isCompleteMessageCandidateNotificationEnabled(candidate)) return;
3853
- const payload = JSON.stringify({
3854
- type: "complete_message_notification",
3855
- source: "matrix_committed_event",
3856
- candidate,
4135
+ function recordCompletedTurnNotificationProjectionDiagnostic(completion) {
4136
+ const identity = completion?.identity || {};
4137
+ const terminal = completion?.terminal || {};
4138
+ const reason = completion?.diagnostics?.notification_projection_reason;
4139
+ const key = [
4140
+ reason,
4141
+ identity.host_session_id,
4142
+ identity.matrix_room_id,
4143
+ identity.provider_turn_id,
4144
+ terminal.completion_event_id,
4145
+ ].join(":");
4146
+ if (completedTurnNotificationProjectionDiagnosticKeys.has(key)) return;
4147
+ completedTurnNotificationProjectionDiagnosticKeys.add(key);
4148
+ if (completedTurnNotificationProjectionDiagnosticKeys.size > 500) {
4149
+ const oldest = completedTurnNotificationProjectionDiagnosticKeys.values().next().value;
4150
+ if (oldest) completedTurnNotificationProjectionDiagnosticKeys.delete(oldest);
4151
+ }
4152
+ console.warn("[p218-notification] completed-message projection failed closed", {
4153
+ reason,
4154
+ projection_source: "shared_completed_turn_service",
4155
+ host_session_id: identity.host_session_id || null,
4156
+ matrix_room_id: identity.matrix_room_id || null,
4157
+ provider_turn_id: identity.provider_turn_id || null,
4158
+ completion_event_id: terminal.completion_event_id || null,
4159
+ });
4160
+ }
4161
+
4162
+ function commitSessionCollectionChange(changeKinds, affectedSessionIds = []) {
4163
+ return sessionCollectionService.reconcile({
4164
+ changeKinds,
4165
+ affectedSessionIds,
4166
+ });
4167
+ }
4168
+
4169
+ function admitSessionCollectionSession(
4170
+ sessionId,
4171
+ changeKinds = ["create"],
4172
+ { required = false } = {}
4173
+ ) {
4174
+ const session = sessionManager.getSession(sessionId);
4175
+ const binding = getRouteCMatrixRoomBinding(sessionId);
4176
+ const admittedSessionId = admitSessionCollectionLiveSession({
4177
+ session,
4178
+ binding,
4179
+ admittedSessionIds: sessionCollectionAdmittedSessionIds,
4180
+ required,
3857
4181
  });
3858
- for (const ws of [...completeMessageNotificationSubscribers]) {
4182
+ if (!admittedSessionId) return null;
4183
+ return commitSessionCollectionChange(changeKinds, [sessionId]);
4184
+ }
4185
+
4186
+ sessionCollectionService.subscribe((event) => {
4187
+ const payload = JSON.stringify(event);
4188
+ for (const ws of [...sessionCollectionSubscribers]) {
3859
4189
  if (ws.readyState !== 1) {
3860
- completeMessageNotificationSubscribers.delete(ws);
4190
+ sessionCollectionSubscribers.delete(ws);
3861
4191
  continue;
3862
4192
  }
3863
4193
  try {
3864
4194
  ws.send(payload);
3865
4195
  } catch {
3866
- completeMessageNotificationSubscribers.delete(ws);
4196
+ sessionCollectionSubscribers.delete(ws);
3867
4197
  }
3868
4198
  }
3869
- }
4199
+ });
4200
+
4201
+ const broadcastCompleteMessageNotificationCandidate =
4202
+ createCompleteMessageNotificationBroadcaster({
4203
+ subscribers: completeMessageNotificationSubscribers,
4204
+ isEnabled: isCompleteMessageCandidateNotificationEnabled,
4205
+ });
3870
4206
 
3871
4207
  function broadcastMailNotificationCandidate(candidate) {
3872
4208
  if (!candidate || candidate.source !== "mail_item_committed") return;
@@ -4456,6 +4792,13 @@ sessionManager.on("runtimeEvent", (agentId, event) => {
4456
4792
  const enrichedEvent = ensurePersistableEventFields(
4457
4793
  attachRuntimeTurnId(event, () => null)
4458
4794
  );
4795
+ if (enrichedEvent.type === "session.ready") {
4796
+ const readySessionId =
4797
+ enrichedEvent.sessionId || enrichedEvent.session_id;
4798
+ if (sessionManager.getSession(readySessionId)?.hostRestartRestore) {
4799
+ admitSessionCollectionSession(readySessionId, ["restore"]);
4800
+ }
4801
+ }
4459
4802
  recordRuntimeEventTelemetry(enrichedEvent);
4460
4803
  if (
4461
4804
  enrichedEvent.type === "session.notice" &&
@@ -4496,6 +4839,24 @@ sessionManager.on("runtimeEvent", (agentId, event) => {
4496
4839
  }
4497
4840
  }
4498
4841
  }
4842
+ if (
4843
+ enrichedEvent.type === "session.ready" &&
4844
+ callbackHostIntegration
4845
+ ) {
4846
+ const readySessionId =
4847
+ enrichedEvent.sessionId || enrichedEvent.session_id || null;
4848
+ if (readySessionId) {
4849
+ setImmediate(async () => {
4850
+ try {
4851
+ await callbackHostIntegration.reconcileSourceReady(readySessionId);
4852
+ } catch (err) {
4853
+ console.warn(
4854
+ `[p238-callback] source-ready reconciliation failed: ${err.message || err}`
4855
+ );
4856
+ }
4857
+ });
4858
+ }
4859
+ }
4499
4860
  const routeCSemanticRuntimeTypes = new Set([
4500
4861
  "message.assistant",
4501
4862
  "message.thinking",
@@ -4519,6 +4880,21 @@ sessionManager.on("runtimeEvent", (agentId, event) => {
4519
4880
  ) {
4520
4881
  scheduleRouteCSemanticBridgeWrite(enrichedEvent);
4521
4882
  }
4883
+ const lifecycleSessionId =
4884
+ enrichedEvent.sessionId || enrichedEvent.session_id || null;
4885
+ if (
4886
+ lifecycleSessionId &&
4887
+ (enrichedEvent.type === "outbox.message" ||
4888
+ enrichedEvent.type === "control.request" ||
4889
+ enrichedEvent.type === "control.outcome" ||
4890
+ (enrichedEvent.type === "session.notice" &&
4891
+ enrichedEvent.subtype === "delivery.state"))
4892
+ ) {
4893
+ commitSessionCollectionChange(
4894
+ ["provider_lifecycle"],
4895
+ [lifecycleSessionId]
4896
+ );
4897
+ }
4522
4898
  broadcast(enrichedEvent.sessionId || enrichedEvent.session_id || null, {
4523
4899
  type: "event",
4524
4900
  event: enrichedEvent,
@@ -4855,6 +5231,115 @@ function materializeRouteCMatrixBindingForSessionSend(session) {
4855
5231
  };
4856
5232
  }
4857
5233
 
5234
+ function sessionTagLabelOwners() {
5235
+ // Current live rows may outlive the bounded retained history window.
5236
+ const owners = new Map(
5237
+ getSessionHistory().map((row) => [row.session_id, row])
5238
+ );
5239
+ for (const row of sessionManager.list())
5240
+ owners.set(row.id || row.sessionId, row);
5241
+ return [...owners.values()];
5242
+ }
5243
+
5244
+ function currentSessionTagLabels() {
5245
+ return new SessionTagLabelsStore(getConfigDir()).labelsFor(
5246
+ sessionTagLabelOwners()
5247
+ );
5248
+ }
5249
+
5250
+ function saveSuccessfulSessionTags(
5251
+ session,
5252
+ tags,
5253
+ {
5254
+ promote = true,
5255
+ admission = false,
5256
+ changeKinds = ["tags", "category"],
5257
+ restoreReady = false,
5258
+ } = {}
5259
+ ) {
5260
+ const restoreHistory = captureSessionTagsHistoryRollback();
5261
+ const previousTags = session.tags;
5262
+ const wasDeferred = session.tagsPersistenceDeferred;
5263
+ const wasAdmitted = sessionCollectionAdmittedSessionIds.has(session.id);
5264
+ const previousRestoreOutcome = restoreReady
5265
+ ? sessionManager.getDesiredOpenRestoreOutcome(session.id)
5266
+ : null;
5267
+ const saved = commitSessionTagSave({
5268
+ tags,
5269
+ store: new SessionTagLabelsStore(getConfigDir()),
5270
+ owners: sessionTagLabelOwners,
5271
+ promote,
5272
+ writeTags: (tags) => {
5273
+ updateSessionRecord(session.id, { tags });
5274
+ session.tags = tags;
5275
+ if (restoreReady) sessionManager.markDesiredOpenRestoreReady(session.id);
5276
+ },
5277
+ rollbackTags: () => {
5278
+ restoreHistory();
5279
+ session.tags = previousTags;
5280
+ session.tagsPersistenceDeferred = wasDeferred;
5281
+ if (previousRestoreOutcome) {
5282
+ sessionManager.desiredOpenRestoreOutcomes.set(
5283
+ session.id,
5284
+ previousRestoreOutcome
5285
+ );
5286
+ }
5287
+ },
5288
+ admit: () => {
5289
+ if (admission)
5290
+ admitSessionCollectionLiveSession({
5291
+ session,
5292
+ binding: getRouteCMatrixRoomBinding(session.id),
5293
+ admittedSessionIds: sessionCollectionAdmittedSessionIds,
5294
+ required: true,
5295
+ });
5296
+ },
5297
+ publish: () =>
5298
+ commitSessionCollectionChange(
5299
+ changeKinds,
5300
+ [session.id]
5301
+ ),
5302
+ rollbackPublication: () =>
5303
+ commitSessionCollectionChange(
5304
+ ["tags", "category"],
5305
+ [session.id]
5306
+ ),
5307
+ rollbackAdmission: () => {
5308
+ if (!wasAdmitted) sessionCollectionAdmittedSessionIds.delete(session.id);
5309
+ },
5310
+ });
5311
+ session.tagsPersistenceDeferred = false;
5312
+ if (promote) recordSuccessfulSessionTagsUse(session, saved);
5313
+ return saved;
5314
+ }
5315
+
5316
+ function commitSuccessfulSessionTags(
5317
+ session,
5318
+ { promote = true, changeKinds = ["create"], restoreReady = false } = {}
5319
+ ) {
5320
+ return saveSuccessfulSessionTags(session, canonicalSessionTags(session), {
5321
+ promote,
5322
+ admission: true,
5323
+ restoreReady,
5324
+ changeKinds: [...changeKinds, "tags", "category"],
5325
+ });
5326
+ }
5327
+
5328
+ function refreshSessionBookkeepingWarning(response, session) {
5329
+ response.bookkeeping_warning = (session.bookkeepingWarnings || []).join("; ") || null;
5330
+ }
5331
+
5332
+ function recordSuccessfulSessionTagsUse(session, tags) {
5333
+ try {
5334
+ new RecentSessionTagsStore(getConfigDir()).recordSuccessfulUse(tags);
5335
+ } catch (err) {
5336
+ session.bookkeepingWarnings = [
5337
+ ...(session.bookkeepingWarnings || []),
5338
+ `recent_session_tags_unavailable: ${err.message || String(err)}`,
5339
+ ];
5340
+ }
5341
+ }
5342
+
4858
5343
  function clearTelegramMockRuntimeForSession(sessionId, reason) {
4859
5344
  const normalizedSessionId = normalizeString(sessionId);
4860
5345
  if (!normalizedSessionId) {
@@ -6008,7 +6493,7 @@ function buildProviderModelRefreshRunnerOptions() {
6008
6493
  backgroundRefreshEnabled: explicitEnable !== false,
6009
6494
  backgroundRefreshSource:
6010
6495
  explicitEnable === null
6011
- ? "default_product_background_24h"
6496
+ ? "default_product_background_daily_0900"
6012
6497
  : PROVIDER_MODEL_REFRESH_BACKGROUND_ENABLE_ENV,
6013
6498
  backgroundRefreshDisabledReason:
6014
6499
  explicitEnable === false
@@ -6692,6 +7177,8 @@ function loadActiveHostLifecycleToolchainBundle() {
6692
7177
  }
6693
7178
 
6694
7179
  const OYSTERUN_NPM_PACKAGE_NAME = "oysterun";
7180
+ const P233_RUNNING_PACKAGE_VERSION = readOysterunPackageVersion();
7181
+ const p233AuthenticatedUserOrigins = new Map();
6695
7182
  const OYSTERUN_UPDATE_DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
6696
7183
  const OYSTERUN_UPDATE_RELEASE_CHANNEL = "latest";
6697
7184
  const OYSTERUN_UPDATE_AUTOMATIC_COOLDOWN_MS = 30 * 60 * 1000;
@@ -6877,8 +7364,11 @@ function getCachedNewestVersion(source = "status") {
6877
7364
  }
6878
7365
 
6879
7366
  function reconcileOysterunUpdateOperation(currentVersion) {
6880
- let operation = readUpdateOperationState({ configDir: getConfigDir() });
7367
+ let operation = reconcileFailedInitialPreflight({ configDir: getConfigDir(), currentVersion });
6881
7368
  if (!operation.operation_id) return null;
7369
+ if (operation.continuity_path === continuityReceiptPath(getConfigDir(), operation.operation_id)) {
7370
+ try { operation = projectContinuityOperation(operation, readContinuity(operation.continuity_path, operation.operation_id)); } catch { /* Missing authority stays blocked. */ }
7371
+ }
6882
7372
  const targetVersion = normalizeString(operation.target_version);
6883
7373
  const targetVersionComparison = targetVersion
6884
7374
  ? compareOysterunSemver(currentVersion, targetVersion)
@@ -6908,6 +7398,16 @@ function reconcileOysterunUpdateOperation(currentVersion) {
6908
7398
  }
6909
7399
  if (shouldRequireUpdateOperationRecovery(operation)) {
6910
7400
  const restartOnly = operation.transaction_kind === "restart_only";
7401
+ if (operation.continuity_path) {
7402
+ try {
7403
+ const r = readContinuity(operation.continuity_path, operation.operation_id);
7404
+ if (!["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(r.state)) {
7405
+ const failed = recordContinuityRecovery(operation.continuity_path, operation.operation_id,
7406
+ continuityFailure(restartOnly ? "restart_runner" : "update_runner", "runner_lease_expired", "The external lifecycle runner did not publish a terminal receipt before its bounded lease expired."));
7407
+ operation = projectContinuityOperation(operation, failed);
7408
+ }
7409
+ } catch { /* Missing authority is reported by the recovery plan. */ }
7410
+ }
6911
7411
  operation = writeUpdateOperationState(
6912
7412
  {
6913
7413
  ...operation,
@@ -6923,8 +7423,8 @@ function reconcileOysterunUpdateOperation(currentVersion) {
6923
7423
  ? "The Host restart runner did not publish a terminal receipt before its bounded lease expired."
6924
7424
  : "The update restart runner did not publish a terminal receipt before its bounded lease expired.",
6925
7425
  recovery_boundary: operation.status,
6926
- error:
6927
- "The update transaction needs operator recovery before another update or restart.",
7426
+ error: restartOnly ? "The Host restart needs recovery before another Host action."
7427
+ : "The update transaction needs recovery before another update or restart.",
6928
7428
  error_redacted: true,
6929
7429
  },
6930
7430
  { configDir: getConfigDir() }
@@ -6939,6 +7439,8 @@ function serializeOysterunUpdateOperation(operation) {
6939
7439
  operation_id: operation.operation_id,
6940
7440
  attempt_id: operation.attempt_id,
6941
7441
  transaction_kind: operation.transaction_kind,
7442
+ continuity_state: operation.continuity_state || null,
7443
+ user_path_verification: operation.user_path_verification || null,
6942
7444
  requested_action: operation.requested_action,
6943
7445
  status: operation.status,
6944
7446
  phase: operation.phase,
@@ -7282,6 +7784,8 @@ function digestOriginalLifecycleEvidence(operation, resultReceipt) {
7282
7784
  return createHash("sha256").update(canonical).digest("hex");
7283
7785
  }
7284
7786
 
7787
+ const continuityAutomaticRechecks = new Set();
7788
+
7285
7789
  function maybeCloseSupersededOysterunUpdateRecovery() {
7286
7790
  const currentVersion = readOysterunPackageVersion();
7287
7791
  let operation = reconcileOysterunUpdateOperation(currentVersion);
@@ -7294,6 +7798,29 @@ function maybeCloseSupersededOysterunUpdateRecovery() {
7294
7798
  update_operation: null,
7295
7799
  };
7296
7800
  }
7801
+ if (operation.continuity_path) {
7802
+ let receipt = null;
7803
+ try {
7804
+ if (operation.continuity_path !== continuityReceiptPath(getConfigDir(), operation.operation_id)) throw new Error("continuity path mismatch");
7805
+ receipt = readContinuity(operation.continuity_path, operation.operation_id);
7806
+ } catch { /* The recovery projection fails closed on missing authority. */ }
7807
+ if (receipt?.state === "RECOVERY_REQUIRED" && !operation.running && !isProcessRunning(operation.runner_pid) &&
7808
+ (operation.recovery_boundary === "p233_user_path_pending" || receipt.first_failure?.code === "p233_user_path_pending" ||
7809
+ (!receipt.restart_attempted && !receipt.restart_id)) && !continuityAutomaticRechecks.has(operation.operation_id)) {
7810
+ continuityAutomaticRechecks.add(operation.operation_id);
7811
+ try {
7812
+ scheduleContinuityRecovery({ kind: "restore_and_restart", source: "automatic_recheck", recheckOnly: true });
7813
+ operation = readUpdateOperationState({ configDir: getConfigDir() });
7814
+ } catch (error) {
7815
+ console.warn("[lifecycle] Automatic restoration recheck could not start:", sanitizeLifecycleDiagnostic(error.message));
7816
+ }
7817
+ }
7818
+ const recoveryPlan = buildHostLifecycleRecoveryPlan({ operation, continuityReceipt: receipt,
7819
+ runnerAlive: isProcessRunning(operation.runner_pid) });
7820
+ return { status: "continuity_operation_observed", reconciled: false,
7821
+ recovery_boundary: recoveryPlan.reason_code, recovery_plan: recoveryPlan,
7822
+ recovery_action: recoveryPlan.user_action, update_operation: serializeOysterunUpdateOperation(operation) };
7823
+ }
7297
7824
  if (operation.status === "superseded_reconciled") {
7298
7825
  const configuredReceiptPath = normalizeString(
7299
7826
  operation.reconciliation_receipt_path
@@ -7663,13 +8190,13 @@ function reconcileSupersededOysterunUpdateRecovery() {
7663
8190
  }
7664
8191
 
7665
8192
  function buildOysterunBetaUpdateStatusPayload(extra = {}) {
7666
- const currentVersion = readOysterunPackageVersion();
8193
+ const currentVersion = P233_RUNNING_PACKAGE_VERSION;
7667
8194
  const betaVersion = normalizeString(extra.beta_version);
7668
8195
  const classification = betaVersion
7669
8196
  ? classifyOysterunBetaVersion({ currentVersion, betaVersion })
7670
8197
  : { relation: "unknown", install_available: false };
7671
8198
  const recoveryResolution = maybeCloseSupersededOysterunUpdateRecovery();
7672
- const operation = readUpdateOperationState({ configDir: getConfigDir() });
8199
+ const operation = reconcileOysterunUpdateOperation(currentVersion) || readUpdateOperationState({ configDir: getConfigDir() });
7673
8200
  const updateSupported = isOysterunUpdateSupported();
7674
8201
  return {
7675
8202
  status: extra.status || "beta_update_status",
@@ -7722,7 +8249,9 @@ function buildOysterunBetaUpdateStatusPayload(extra = {}) {
7722
8249
  }
7723
8250
 
7724
8251
  function buildOysterunUpdateStatusPayload(extra = {}) {
7725
- const currentVersion = readOysterunPackageVersion();
8252
+ // Package exchange precedes restart. Keep the user-visible current version
8253
+ // bound to this running coordinator, including failed pre-handoff updates.
8254
+ const currentVersion = P233_RUNNING_PACKAGE_VERSION;
7726
8255
  const channel = normalizeOysterunUpdateChannel(extra.channel, currentVersion);
7727
8256
  const source = normalizeOysterunUpdateSource(extra.source, "status");
7728
8257
  const reminder = readUpdateReminderState({ configDir: getConfigDir() });
@@ -7730,7 +8259,7 @@ function buildOysterunUpdateStatusPayload(extra = {}) {
7730
8259
  normalizeString(extra.newest_version || extra.latest_version) ||
7731
8260
  getCachedNewestVersion(source);
7732
8261
  const recoveryResolution = maybeCloseSupersededOysterunUpdateRecovery();
7733
- const operation = readUpdateOperationState({ configDir: getConfigDir() });
8262
+ const operation = reconcileOysterunUpdateOperation(currentVersion) || readUpdateOperationState({ configDir: getConfigDir() });
7734
8263
  const updateAvailable = newestVersion
7735
8264
  ? compareOysterunSemver(newestVersion, currentVersion) === 1
7736
8265
  : false;
@@ -8225,52 +8754,237 @@ function buildHostActiveRuntimeStatusSummary() {
8225
8754
  };
8226
8755
  }
8227
8756
 
8228
- function buildHostRestartRuntimeSnapshot({ trigger }) {
8229
- return {
8230
- trigger,
8231
- captured_at: new Date().toISOString(),
8232
- sessions: sessionManager.snapshotHostRestartState(),
8233
- loops: schedulerService.snapshotSessionLoopRestartState(),
8234
- scheduler: schedulerService.snapshotHostRestartState(),
8235
- terminal: terminalSessionManager.snapshotHostRestartState(),
8236
- shell_execs: buildActiveShellExecRestartSnapshot(),
8237
- telegram: telegramBridgeManager.snapshotHostRestartState(),
8238
- prompt_replay_policy: HOST_RESTART_RESTORE_NO_PROMPT_REPLAY_POLICY,
8239
- matrix_transcript_mutation: false,
8240
- };
8241
- }
8242
-
8243
- function buildHostRestartOperationLogPath() {
8244
- const logDir = join(getConfigDir(), "operation_logs");
8245
- mkdirSync(logDir, { recursive: true });
8246
- return join(
8247
- logDir,
8248
- `host-restart-restore-${new Date().toISOString().replace(/[:.]/g, "-")}.log`
8249
- );
8250
- }
8251
-
8252
- function assertHostRestartSchedulingAvailable() {
8253
- const stackName = getActiveStackName();
8254
- if (!stackName) {
8255
- throw new Error(
8256
- "Host restart is unavailable because the active stack name is not known."
8257
- );
8757
+ function recordContinuityAuthenticatedUserPath(req) {
8758
+ const config = readConfig();
8759
+ const incomingHost = normalizeString(req.headers["x-forwarded-host"] || req.headers.host);
8760
+ let configuredRouteRecorded = false;
8761
+ for (const candidate of [config.managed_endpoint, config.direct_host_url, config.public_base_url]) {
8762
+ if (!candidate) continue;
8763
+ const origin = new URL(candidate).origin;
8764
+ if (new URL(origin).host !== incomingHost) continue;
8765
+ p233AuthenticatedUserOrigins.set(origin, { pid: process.pid, observed_at: new Date().toISOString() });
8766
+ configuredRouteRecorded = true;
8767
+ }
8768
+ if (configuredRouteRecorded) {
8769
+ completeContinuityUserPathEvidence();
8770
+ return;
8258
8771
  }
8259
- if (isBoundaryTestRuntime()) {
8260
- throw new Error("Host restart is unavailable from boundary test runtime.");
8772
+ if (!incomingHost) return;
8773
+ // A route-less local stack is intentionally reachable only through its
8774
+ // exact loopback port. Do not trust an arbitrary Host header as continuity
8775
+ // authority; accept only loopback aliases bound to this running Host.
8776
+ let incoming;
8777
+ try {
8778
+ incoming = new URL(`http://${incomingHost}`);
8779
+ } catch {
8780
+ return;
8261
8781
  }
8262
- const restartScript = join(REPO_ROOT, "tool_scripts", "restart_oysterun.sh");
8263
- if (!existsSync(restartScript)) {
8264
- throw new Error(`Host restart script is missing: ${restartScript}`);
8782
+ if (
8783
+ !["127.0.0.1", "localhost", "[::1]"].includes(incoming.hostname) ||
8784
+ Number(incoming.port || 80) !== Number(PORT)
8785
+ ) {
8786
+ return;
8265
8787
  }
8266
- return { stackName, restartScript };
8788
+ const proof = { pid: process.pid, observed_at: new Date().toISOString() };
8789
+ for (const origin of [
8790
+ `http://127.0.0.1:${PORT}`,
8791
+ `http://localhost:${PORT}`,
8792
+ `http://[::1]:${PORT}`,
8793
+ ]) {
8794
+ p233AuthenticatedUserOrigins.set(origin, proof);
8795
+ }
8796
+ completeContinuityUserPathEvidence();
8267
8797
  }
8268
8798
 
8269
- function runCompleteHostLifecyclePreflight({
8270
- toolchain,
8271
- allowOperationId = null,
8272
- expectedCurrentHostCwd = null,
8273
- expectedSourceServiceDefinitionSha256 = null,
8799
+ function completeContinuityUserPathEvidence() {
8800
+ try {
8801
+ const operation = readUpdateOperationState({ configDir: getConfigDir() });
8802
+ if (!operation.operation_id || operation.continuity_path !== continuityReceiptPath(getConfigDir(), operation.operation_id)) return;
8803
+ const receipt = readContinuity(operation.continuity_path, operation.operation_id);
8804
+ const origin = receipt.user_path_verification?.origin;
8805
+ const proof = p233AuthenticatedUserOrigins.get(origin);
8806
+ if (proof?.pid === process.pid) recordContinuityUserPath(operation.continuity_path, operation.operation_id, { origin, ...proof });
8807
+ } catch (error) {
8808
+ console.warn("[lifecycle] Could not record supplementary login verification:", sanitizeLifecycleDiagnostic(error.message));
8809
+ }
8810
+ }
8811
+
8812
+ function buildHostContinuityObservation(retained = null) {
8813
+ const config = readConfig();
8814
+ const stack = getActiveStackName();
8815
+ const service = getUpdateServiceDefinitionIdentity(stack);
8816
+ const toolchain = loadActiveHostLifecycleToolchainBundle();
8817
+ if (realpathSync(process.execPath) !== toolchain.node_realpath) {
8818
+ throw continuityFailure("host_qualification", "running_node", "Running Host executable differs from the persisted Node runtime.");
8819
+ }
8820
+ const host = {
8821
+ version: P233_RUNNING_PACKAGE_VERSION, pid: process.pid, port: PORT, stack,
8822
+ package_root: REPO_ROOT, process_cwd: realpathSync(process.cwd()),
8823
+ node_version: process.version, runtime_identity_digest: toolchain.toolchain_identity_digest,
8824
+ device_id: normalizeString(config.device_id || config.host_id || DEVICE_ID),
8825
+ config_dir: realpathSync(getConfigDir()), host_folder: realpathSync(getConfigDir()),
8826
+ browser_root: realpathSync(resolveDefaultBrowsePath()),
8827
+ connection_mode: CONNECTION_MODE,
8828
+ managed_endpoint: normalizeString(config.managed_endpoint),
8829
+ direct_host_url: normalizeString(config.direct_host_url || config.public_base_url || DIRECT_HOST_URL),
8830
+ service_definition_sha256: existsSync(service.definitionPath)
8831
+ ? continuityDigest(readFileSync(service.definitionPath)) : "absent",
8832
+ service_definition_path: service.definitionPath,
8833
+ };
8834
+ const sessions = sessionManager.snapshotHostRestartState()
8835
+ .filter((row) => row.alive_before_restart || row.ready_before_restart)
8836
+ .map((row) => {
8837
+ try {
8838
+ const binding = getRouteCMatrixRoomBinding(row.session_id);
8839
+ if (!binding) throw continuityFailure("session_qualification", "matrix_binding", `Session ${row.session_id} has no committed Matrix binding.`);
8840
+ const prior = retained?.sessions.find((entry) => entry.session_id === row.session_id);
8841
+ const anchorIds = prior ? prior.history_anchors.map((entry) => entry.event_id)
8842
+ : readMatrixTranscriptRecentTail({ binding, limit: 2 }).messages.map((message) => message.matrix_event_id);
8843
+ const historyAnchors = anchorIds.map((eventId) => {
8844
+ const event = readMatrixTranscriptContext({ binding, eventId, before: 0, after: 0 })?.target_message;
8845
+ if (!event) throw continuityFailure("session_qualification", "history_anchor_missing", `Session ${row.session_id} lost a retained Matrix event.`);
8846
+ return { event_id: eventId, sha256: continuityDigest(event) };
8847
+ });
8848
+ return {
8849
+ session_id: row.session_id, agent_id: row.agent_id, agent_folder: realpathSync(row.agent_folder),
8850
+ provider: row.provider, provider_resume_id: row.provider_resume_id,
8851
+ resume_identity_kind: row.resume_identity_kind || null,
8852
+ provider_thread_id: row.provider_thread_id || row.provider_resume_id,
8853
+ matrix_binding_host_session_id: binding.host_session_id,
8854
+ matrix_room_id: binding.matrix_room_id,
8855
+ adapter_resume_identity_kind: getAdapterResumeIdentityKind(sessionManager.getAdapter(row.provider)),
8856
+ adapter_registered: Boolean(sessionManager.getAdapter(row.provider)),
8857
+ alive: row.alive_before_restart, ready: row.ready_before_restart,
8858
+ history_anchors: historyAnchors,
8859
+ };
8860
+ } catch (error) {
8861
+ return { session_id: row.session_id, observation_error: error.code || "session_observation_failed" };
8862
+ }
8863
+ });
8864
+ const baseline = { host, sessions, captured_at: new Date().toISOString() };
8865
+ const restore = readHostRestartRestoreState({ configDir: getConfigDir() });
8866
+ const routeOrigin = resolveContinuityQualificationOrigin({
8867
+ connectionMode: host.connection_mode,
8868
+ managedEndpoint: host.managed_endpoint,
8869
+ directHostUrl: host.direct_host_url,
8870
+ loopbackOrigin: `http://127.0.0.1:${PORT}`,
8871
+ });
8872
+ return {
8873
+ baseline, host, sessions, desired_session_restore: sessionManager.getDesiredOpenRestoreStatus(),
8874
+ restore_result: restore?.restore_result || null,
8875
+ restart_id: restore?.restart_id || null, restart_state: restore?.state || null,
8876
+ authenticated_user_path: p233AuthenticatedUserOrigins.get(routeOrigin) || null,
8877
+ };
8878
+ }
8879
+
8880
+ function prepareHostContinuity({ operationId, targetVersion, toolchain, transactionKind, restartState = null }) {
8881
+ const path = continuityReceiptPath(getConfigDir(), operationId);
8882
+ const { baseline } = buildHostContinuityObservation();
8883
+ const authority = {};
8884
+ if (restartState) {
8885
+ authority.restart_id = restartState.restart_id;
8886
+ authority.restart_only_snapshot = retainContinuityFile(getHostRestartRestoreStatePath(getConfigDir()), `${path}.restore`);
8887
+ }
8888
+ const operation = readUpdateOperationState({ configDir: getConfigDir() });
8889
+ if (operation.operation_id !== operationId) throw continuityFailure("preparation", "operation_binding", "Lifecycle operation changed before its durable preparation.");
8890
+ writeUpdateOperationState({ ...operation, continuity_path: path }, { configDir: getConfigDir() });
8891
+ prepareContinuity({ path, operationId, transactionKind, authority,
8892
+ source: { version: P233_RUNNING_PACKAGE_VERSION, runtime_identity_digest: toolchain.toolchain_identity_digest,
8893
+ package_tree_sha256: digestContinuityPackage(REPO_ROOT), service_definition_sha256: baseline.host.service_definition_sha256 },
8894
+ target: { version: targetVersion, runtime_identity_digest: toolchain.toolchain_identity_digest }, baseline });
8895
+ compareContinuityIdentities(baseline.sessions, baseline.sessions, { path, operationId, stage: "preparation" });
8896
+ for (const row of baseline.sessions) {
8897
+ if (row.observation_error || !hasCompleteContinuityResumeIdentity(row) || !Array.isArray(row.history_anchors)) {
8898
+ throw continuityFailure("preparation", "session_resume_unavailable", `Session ${row.session_id} has no complete provider resume/history identity.`);
8899
+ }
8900
+ }
8901
+ if (restartState) assertFreshContinuitySnapshot(baseline, restartState.restart_id, restartState, { path, operationId });
8902
+ createContinuityProbeGrant(getConfigDir(), operationId);
8903
+ return path;
8904
+ }
8905
+
8906
+ function assertSessionRosterMutationAvailable() {
8907
+ const operation = readUpdateOperationState({ configDir: getConfigDir() });
8908
+ if (!operation.continuity_path || !isUpdateOperationBlocking(operation)) return;
8909
+ const receipt = readContinuity(operation.continuity_path, operation.operation_id);
8910
+ if (!receipt.restart_id || ["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return;
8911
+ const error = new Error("Host lifecycle recovery is preserving the fresh live Sessions. Finish that operation before changing Sessions.");
8912
+ error.code = "oysterun_session_roster_reserved";
8913
+ throw error;
8914
+ }
8915
+
8916
+ function recordContinuityRunnerHandoffFailure({ operationId, error, gateId = null, stage = null, check = null }) {
8917
+ const current = readUpdateOperationState({ configDir: getConfigDir() });
8918
+ if (current.operation_id !== operationId || !current.continuity_path) return;
8919
+ const prior = readContinuity(current.continuity_path, operationId);
8920
+ if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(prior.state)) return;
8921
+ const restartOnly = prior.transaction_kind === "restart_only";
8922
+ const failure = continuityFailure(stage || (restartOnly ? "restart_handoff" : "update_handoff"), check || (restartOnly ? "restart_runner_spawn_failed" : "update_runner_spawn_failed"),
8923
+ sanitizeLifecycleDiagnostic(error?.message || "The external lifecycle runner could not start."));
8924
+ const receipt = prior.state === "COMMITTED" ? prior : recordContinuityRecovery(current.continuity_path, operationId, failure);
8925
+ writeUpdateOperationState({ ...projectContinuityOperation(current, receipt),
8926
+ status: restartOnly ? "restart_recovery_required" : "update_recovery_required",
8927
+ phase: prior.state === "COMMITTED" ? "Committed cleanup requires continuation" : "External runner unavailable; recovery assets retained",
8928
+ running: false, runner_pid: null, runner_lease_expires_at: null, lifecycle_owner_pid: null,
8929
+ recovery_required_at: new Date().toISOString(), recovery_boundary: failure.code,
8930
+ recovery_reason: failure.message, error: failure.message, error_redacted: true,
8931
+ }, { configDir: getConfigDir() });
8932
+ releaseHostRestartLoginGateSafely({ gateId, reason: "continuity_runner_spawn_failed", source: "Continuity runner handoff" });
8933
+ }
8934
+
8935
+ function buildHostRestartRuntimeSnapshot({ trigger }) {
8936
+ return {
8937
+ trigger,
8938
+ captured_at: new Date().toISOString(),
8939
+ sessions: sessionManager.snapshotHostRestartState().map((row) => {
8940
+ const binding = getRouteCMatrixRoomBinding(row.session_id);
8941
+ return { ...row,
8942
+ matrix_binding_host_session_id: binding?.host_session_id || null,
8943
+ matrix_room_id: binding?.matrix_room_id || null,
8944
+ adapter_resume_identity_kind: getAdapterResumeIdentityKind(sessionManager.getAdapter(row.provider)),
8945
+ adapter_registered: Boolean(sessionManager.getAdapter(row.provider)) };
8946
+ }),
8947
+ loops: schedulerService.snapshotSessionLoopRestartState(),
8948
+ scheduler: schedulerService.snapshotHostRestartState(),
8949
+ terminal: terminalSessionManager.snapshotHostRestartState(),
8950
+ shell_execs: buildActiveShellExecRestartSnapshot(),
8951
+ telegram: telegramBridgeManager.snapshotHostRestartState(),
8952
+ prompt_replay_policy: HOST_RESTART_RESTORE_NO_PROMPT_REPLAY_POLICY,
8953
+ matrix_transcript_mutation: false,
8954
+ };
8955
+ }
8956
+
8957
+ function buildHostRestartOperationLogPath() {
8958
+ const logDir = join(getConfigDir(), "operation_logs");
8959
+ mkdirSync(logDir, { recursive: true });
8960
+ return join(
8961
+ logDir,
8962
+ `host-restart-restore-${new Date().toISOString().replace(/[:.]/g, "-")}.log`
8963
+ );
8964
+ }
8965
+
8966
+ function assertHostRestartSchedulingAvailable() {
8967
+ const stackName = getActiveStackName();
8968
+ if (!stackName) {
8969
+ throw new Error(
8970
+ "Host restart is unavailable because the active stack name is not known."
8971
+ );
8972
+ }
8973
+ if (isBoundaryTestRuntime()) {
8974
+ throw new Error("Host restart is unavailable from boundary test runtime.");
8975
+ }
8976
+ const restartScript = join(REPO_ROOT, "tool_scripts", "restart_oysterun.sh");
8977
+ if (!existsSync(restartScript)) {
8978
+ throw new Error(`Host restart script is missing: ${restartScript}`);
8979
+ }
8980
+ return { stackName, restartScript };
8981
+ }
8982
+
8983
+ function runCompleteHostLifecyclePreflight({
8984
+ toolchain,
8985
+ allowOperationId = null,
8986
+ expectedCurrentHostCwd = null,
8987
+ expectedSourceServiceDefinitionSha256 = null,
8274
8988
  expectedTargetServiceDefinitionSha256 = null,
8275
8989
  } = {}) {
8276
8990
  const { stackName } = assertHostRestartSchedulingAvailable();
@@ -8352,114 +9066,12 @@ function runCompleteHostLifecyclePreflight({
8352
9066
  }
8353
9067
 
8354
9068
  function getUpdateServiceDefinitionIdentity(stackName) {
8355
- const production = stackName === "production";
8356
- const configDir = resolve(getConfigDir());
8357
- const stackRoot = production ? configDir : dirname(configDir);
8358
- const logDir = production ? join(configDir, "logs") : join(stackRoot, "logs");
8359
- const serviceLabel = production ? "com.oysterun.host" : `com.oysterun.host.${stackName}`;
8360
- const stackKind = ["production", "staging", "test1", "test2", "test3", "test4"].includes(stackName)
8361
- ? "preset"
8362
- : "custom";
8363
- return {
8364
- production,
8365
- configDir,
8366
- stackRoot,
8367
- logDir,
8368
- hostLog: join(logDir, stackKind === "custom" ? `oysterun-host-${stackName}.log` : "oysterun-host.log"),
8369
- serviceLabel,
8370
- stackKind,
8371
- definitionPath: join(homedir(), "Library", "LaunchAgents", `${serviceLabel}.plist`),
8372
- };
8373
- }
8374
-
8375
- function resolveFrozenProviderLaunchPath({ toolchain, packageRoot = REPO_ROOT }) {
8376
- const result = spawnSync(
8377
- toolchain.node_path,
8378
- [
8379
- join(packageRoot, "host-service", "provider-command-detection.mjs"),
8380
- "--print-launch-path",
8381
- "--config-path",
8382
- getConfigPath(),
8383
- "--node-bin",
8384
- toolchain.node_path,
8385
- ],
8386
- {
8387
- cwd: packageRoot,
8388
- env: buildNodeNpmChildEnvironment(toolchain, process.env),
8389
- encoding: "utf8",
8390
- stdio: "pipe",
8391
- }
8392
- );
8393
- if (result.error) throw result.error;
8394
- if (result.status !== 0) {
8395
- throw new Error(`target provider resolution failed: ${sanitizeLifecycleDiagnostic(result.stderr || result.stdout)}`);
8396
- }
8397
- const pathEntries = String(result.stdout || "").trim().split(":").filter(Boolean);
8398
- if (pathEntries.length === 0) throw new Error("target provider resolution returned an empty PATH");
8399
- return pathEntries;
9069
+ return getServiceDefinitionIdentity({ stackName, configDir: getConfigDir() });
8400
9070
  }
8401
9071
 
8402
- function buildUpdateServiceRenderBundle({
8403
- toolchain,
8404
- stackName,
8405
- registry,
8406
- packageRoot = REPO_ROOT,
8407
- packageVersion = readOysterunPackageVersion(),
8408
- }) {
8409
- const identity = getUpdateServiceDefinitionIdentity(stackName);
8410
- const manifestRaw = readFileSync(join(packageRoot, "package.json"), "utf8");
8411
- const manifest = JSON.parse(manifestRaw);
8412
- const versionOwned = manifest.oysterun_service_definition || {};
8413
- return {
8414
- schema_version: 1,
8415
- artifact: {
8416
- package_root: resolve(packageRoot),
8417
- package_realpath: realpathSync(packageRoot),
8418
- package_version: packageVersion,
8419
- integrity_sha256: createHash("sha256").update(manifestRaw).digest("hex"),
8420
- },
8421
- host: {
8422
- launch_domain: `gui/${process.getuid?.() ?? 0}`,
8423
- user: process.env.USER || "unknown",
8424
- stack_name: stackName,
8425
- port: PORT,
8426
- config_dir: identity.configDir,
8427
- stack_root: identity.stackRoot,
8428
- log_dir: identity.logDir,
8429
- host_log: identity.hostLog,
8430
- service_label: identity.serviceLabel,
8431
- stack_kind: identity.stackKind,
8432
- production: identity.production,
8433
- },
8434
- runtime: {
8435
- node_path: toolchain.node_path,
8436
- node_realpath: toolchain.node_realpath,
8437
- node_version: toolchain.node_version,
8438
- node_abi: toolchain.node_abi,
8439
- npm_cli_path: toolchain.npm_cli_path,
8440
- npm_cli_realpath: toolchain.npm_cli_realpath,
8441
- npm_version: toolchain.npm_version,
8442
- platform: toolchain.platform,
8443
- architecture: toolchain.architecture,
8444
- toolchain_identity_digest: toolchain.toolchain_identity_digest,
8445
- },
8446
- update: {
8447
- registry_authority: registry.source,
8448
- registry_url: registry.override_active ? registry.url : null,
8449
- state_path: normalizeString(process.env.OYSTERUN_UPDATE_REGISTRY_STATE_PATH),
8450
- },
8451
- provider_resolution: {
8452
- path_entries: resolveFrozenProviderLaunchPath({ toolchain, packageRoot }),
8453
- environment: {},
8454
- },
8455
- version_owned: {
8456
- environment: versionOwned.environment || {},
8457
- top_level: versionOwned.top_level || {},
8458
- run_at_load: versionOwned.run_at_load ?? true,
8459
- keep_alive: versionOwned.keep_alive ?? true,
8460
- throttle_interval: versionOwned.throttle_interval ?? 10,
8461
- },
8462
- };
9072
+ function buildUpdateServiceRenderBundle({ toolchain, stackName, registry, packageRoot = REPO_ROOT, packageVersion = readOysterunPackageVersion() }) {
9073
+ return buildServiceRenderBundle({ toolchain, stackName, registry, packageRoot, packageVersion,
9074
+ configDir: getConfigDir(), configPath: getConfigPath(), port: PORT });
8463
9075
  }
8464
9076
 
8465
9077
  function writeTargetServiceDefinitionAuthority({ receipt, targetBundle, targetRendered }) {
@@ -8652,6 +9264,7 @@ function buildHostRuntimeStatusPayload() {
8652
9264
  sessions: sessionManager.list().length,
8653
9265
  restart_restore: buildHostRestartRestoreSummary(transaction),
8654
9266
  last_restart_restore: lastHostRestartRestoreStatus,
9267
+ desired_open_restore: buildDesiredOpenRestoreStatusPayload(),
8655
9268
  active_runtime: buildHostActiveRuntimeStatusSummary(),
8656
9269
  provider_model_refresh: providerModelRefreshRunner.getStatus(),
8657
9270
  host_runtime_metadata: buildHostRuntimeMetadataStatus(),
@@ -8661,6 +9274,27 @@ function buildHostRuntimeStatusPayload() {
8661
9274
  };
8662
9275
  }
8663
9276
 
9277
+ function buildDesiredOpenRestoreStatusPayload() {
9278
+ const persisted = sessionManager.getDesiredOpenRestoreStatus();
9279
+ return {
9280
+ ...(lastDesiredOpenRestoreStatus || {
9281
+ status: persisted.status === "available" ? "idle" : persisted.status,
9282
+ trigger: null,
9283
+ reconciliation_id: null,
9284
+ running: false,
9285
+ started_at: null,
9286
+ completed_at: null,
9287
+ }),
9288
+ preference_enabled:
9289
+ readConfig().restore_previous_sessions_after_restart !== false,
9290
+ max_concurrency: DESIRED_OPEN_RESTORE_MAX_CONCURRENCY,
9291
+ session_timeout_ms: DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS,
9292
+ sessions: persisted.sessions,
9293
+ prompt_replay: false,
9294
+ terminal_command_replay: false,
9295
+ };
9296
+ }
9297
+
8664
9298
  function serializeProviderModelRefreshResult(result = {}) {
8665
9299
  return {
8666
9300
  status: normalizeString(result.status) || "unknown",
@@ -8726,6 +9360,8 @@ function releaseHostRestartLoginGateSafely({ gateId, reason, source }) {
8726
9360
  async function scheduleOysterunPackageUpdate({
8727
9361
  channel = OYSTERUN_UPDATE_RELEASE_CHANNEL,
8728
9362
  source = "host_preferences",
9363
+ expectedCurrentVersion = null,
9364
+ expectedTargetVersion = null,
8729
9365
  recoveryKind = null,
8730
9366
  recoveryOperation = null,
8731
9367
  } = {}) {
@@ -8926,7 +9562,12 @@ async function scheduleOysterunPackageUpdate({
8926
9562
  },
8927
9563
  { configDir: getConfigDir() }
8928
9564
  );
9565
+ } else {
9566
+ writeUpdateOperationState(failedInitialPreflightState(operationState), {
9567
+ configDir: getConfigDir(),
9568
+ });
8929
9569
  }
9570
+
8930
9571
  throw error;
8931
9572
  }
8932
9573
  if (!repairRequested) {
@@ -9012,24 +9653,27 @@ async function scheduleOysterunPackageUpdate({
9012
9653
  }
9013
9654
  throw new Error(`Could not resolve exact Oysterun package version for ${resolvedChannel}`);
9014
9655
  }
9015
- if (betaUpdate && !repairRequested && updateCheck.install_available !== true) {
9016
- writeUpdateOperationState(
9017
- {
9018
- ...operationState,
9019
- status: "update_check_failed",
9020
- phase: "No newer Beta is available",
9021
- running: false,
9022
- lifecycle_owner_pid: null,
9023
- runner_lease_expires_at: null,
9024
- terminal_result_status: "update_check_failed",
9025
- next_safe_action: null,
9026
- error: "The npm beta version is not newer than the installed version.",
9027
- error_redacted: true,
9028
- },
9029
- { configDir: getConfigDir() }
9030
- );
9031
- throw new Error("The npm beta version is not newer than the installed version.");
9656
+ if (!repairRequested) {
9657
+ try {
9658
+ assertConfirmedUpdateTarget({
9659
+ currentVersion: readOysterunPackageVersion(), targetVersion: exactVersion,
9660
+ expectedCurrentVersion, expectedTargetVersion, source,
9661
+ });
9662
+ } catch (error) {
9663
+ operationState = recordUpdateRootFailure(operationState, {
9664
+ ...error.lifecycle_diagnostic,
9665
+ safe_message: error.message, observed_at: new Date().toISOString(),
9666
+ });
9667
+ writeUpdateOperationState({
9668
+ ...operationState, status: "update_check_failed", phase: "Update confirmation rejected",
9669
+ running: false, lifecycle_owner_pid: null, runner_lease_expires_at: null,
9670
+ terminal_result_status: "update_check_failed", next_safe_action: null,
9671
+ restart_attempted: false, error: error.message, error_redacted: true,
9672
+ }, { configDir: getConfigDir() });
9673
+ throw error;
9674
+ }
9032
9675
  }
9676
+
9033
9677
  if (repairRequested) {
9034
9678
  let metadata;
9035
9679
  try {
@@ -9172,6 +9816,28 @@ async function scheduleOysterunPackageUpdate({
9172
9816
  login_gate_activated: false,
9173
9817
  restore_snapshot_created: false,
9174
9818
  });
9819
+ let continuityPath;
9820
+ try {
9821
+ continuityPath = prepareHostContinuity({
9822
+ operationId: operationState.operation_id, targetVersion: exactVersion,
9823
+ toolchain, transactionKind,
9824
+ });
9825
+ } catch (error) {
9826
+ const retainedPath = continuityReceiptPath(getConfigDir(), operationState.operation_id);
9827
+ const retained = existsSync(retainedPath)
9828
+ ? recordContinuityRecovery(retainedPath, operationState.operation_id, error) : null;
9829
+ operationState = recordUpdateRootFailure(operationState, {
9830
+ stage: "session_preparation", check: error.lifecycle_diagnostic?.check || "fresh_roster",
9831
+ code: error.code || "fresh_roster_failed", safe_message: sanitizeLifecycleDiagnostic(error.message),
9832
+ observed_at: new Date().toISOString(),
9833
+ });
9834
+ writeUpdateOperationState({ ...operationState, continuity_path: retained ? retainedPath : null,
9835
+ status: retained ? "update_recovery_required" : "update_check_failed", phase: "Session preparation did not pass",
9836
+ running: false, lifecycle_owner_pid: null, restart_attempted: false, terminal_result_status: "update_check_failed",
9837
+ error: sanitizeLifecycleDiagnostic(error.message), error_redacted: true }, { configDir: getConfigDir() });
9838
+ throw error;
9839
+ }
9840
+ operationState = writeUpdateOperationState({ ...operationState, continuity_path: continuityPath }, { configDir: getConfigDir() });
9175
9841
  const updateInstallerArgs = [
9176
9842
  join(REPO_ROOT, "tool_scripts", "install_oysterun_update.mjs"),
9177
9843
  "--package-spec",
@@ -9202,6 +9868,8 @@ async function scheduleOysterunPackageUpdate({
9202
9868
  join(getConfigDir(), "service-definition-authority.json"),
9203
9869
  ]
9204
9870
  : []),
9871
+ "--continuity-path",
9872
+ continuityPath,
9205
9873
  "--retain-for-restart",
9206
9874
  "1",
9207
9875
  ...(repairRequested ? ["--force-reinstall", "1"] : []),
@@ -9223,9 +9891,10 @@ async function scheduleOysterunPackageUpdate({
9223
9891
  },
9224
9892
  { configDir: getConfigDir() }
9225
9893
  );
9226
- const logFd = openSync(logPath, "a");
9894
+ let logFd;
9227
9895
  let preparationChild;
9228
9896
  try {
9897
+ logFd = openSync(logPath, "a");
9229
9898
  preparationChild = spawn(toolchain.node_path, updateInstallerArgs, {
9230
9899
  cwd: REPO_ROOT,
9231
9900
  env: buildNodeNpmChildEnvironment(toolchain, {
@@ -9243,8 +9912,11 @@ async function scheduleOysterunPackageUpdate({
9243
9912
  },
9244
9913
  { configDir: getConfigDir() }
9245
9914
  );
9915
+ } catch (error) {
9916
+ recordContinuityRunnerHandoffFailure({ operationId: operationState.operation_id, error, stage: "package_preparation", check: "installer_spawn_failed" });
9917
+ throw error;
9246
9918
  } finally {
9247
- closeSync(logFd);
9919
+ if (logFd !== undefined) closeSync(logFd);
9248
9920
  }
9249
9921
  try {
9250
9922
  await new Promise((resolvePreparation, rejectPreparation) => {
@@ -9270,6 +9942,10 @@ async function scheduleOysterunPackageUpdate({
9270
9942
  // before it can publish its atomic structured result.
9271
9943
  }
9272
9944
  const failureCode = normalizeString(result?.failure_code) || "staging_install_failed";
9945
+ recordContinuityRecovery(continuityPath, operationState.operation_id,
9946
+ Object.assign(new Error(sanitizeLifecycleDiagnostic(result?.error || error.message)), {
9947
+ code: failureCode, lifecycle_diagnostic: { stage: result?.package_mutated ? "activation" : "package_staging", check: failureCode },
9948
+ }));
9273
9949
  operationState = recordUpdateRootFailure(operationState, {
9274
9950
  stage: normalizeString(result?.package_load_status) === "staged_and_activated_verified"
9275
9951
  ? "activation"
@@ -9283,19 +9959,21 @@ async function scheduleOysterunPackageUpdate({
9283
9959
  operationState.diagnostic_summary,
9284
9960
  "rollback",
9285
9961
  {
9286
- result: result?.rollback_completed === true ? "passed" : "not_required",
9962
+ result: result?.rollback_completed === true ? "passed" : result?.package_mutated ? "incomplete" : "not_required",
9287
9963
  code: result?.rollback_completed === true
9288
9964
  ? "prior_package_restored"
9965
+ : result?.package_mutated ? "retained_pair_recovery_pending"
9289
9966
  : "package_activation_not_reached",
9290
9967
  safe_message: result?.rollback_completed === true
9291
9968
  ? "The prior package was restored."
9969
+ : result?.package_mutated ? "The package changed; the retained prior package and definition still require qualified recovery."
9292
9970
  : "Rollback was not required because the active package was not changed.",
9293
9971
  }
9294
9972
  );
9295
9973
  failureSummary = recordLifecycleTerminalOutcome(failureSummary, "recovery", {
9296
9974
  result: "available",
9297
- code: "repair_and_restart_available",
9298
- safe_message: "The update can be retried through the repair and restart action.",
9975
+ code: "retained_continuity_recovery_available",
9976
+ safe_message: "Continue this exact operation through its displayed recovery action; retained assets have not been cleaned.",
9299
9977
  });
9300
9978
  failureSummary = recordLifecycleTerminalOutcome(failureSummary, "cleanup", {
9301
9979
  result: result?.operation_cleanup_completed === true ? "passed" : "incomplete",
@@ -9350,6 +10028,13 @@ async function scheduleOysterunPackageUpdate({
9350
10028
  },
9351
10029
  { configDir: getConfigDir() }
9352
10030
  );
10031
+ continuityAutomaticRechecks.add(operationState.operation_id);
10032
+ try {
10033
+ scheduleContinuityRecovery({ kind: "restore_and_restart", source: "automatic_recovery", automatic: true,
10034
+ recheckOnly: result?.package_mutated !== true });
10035
+ } catch (recoveryError) {
10036
+ console.warn("[lifecycle] Automatic update recovery could not start:", sanitizeLifecycleDiagnostic(recoveryError.message));
10037
+ }
9353
10038
  throw error;
9354
10039
  } finally {
9355
10040
  if (sourceServiceRenderBundle) {
@@ -9360,6 +10045,8 @@ async function scheduleOysterunPackageUpdate({
9360
10045
  try {
9361
10046
  retainedPreparationResult = JSON.parse(readFileSync(resultPath, "utf8"));
9362
10047
  } catch (error) {
10048
+ recordContinuityRecovery(continuityPath, operationState.operation_id,
10049
+ continuityFailure("activation", "preparation_receipt_readback", "Update preparation completed without a readable result receipt."));
9363
10050
  operationState = recordUpdateRootFailure(operationState, {
9364
10051
  stage: "activation",
9365
10052
  check: "preparation_receipt_readback",
@@ -9597,6 +10284,11 @@ async function scheduleOysterunPackageUpdate({
9597
10284
  service_definition_commit_started_at: new Date().toISOString(),
9598
10285
  };
9599
10286
  writeRecoveryReconciliationReceipt(resultPath, retainedPreparationResult);
10287
+ {
10288
+ const continuity = readContinuity(continuityPath, operationState.operation_id);
10289
+ writeContinuityAtomic(continuityPath, { ...continuity,
10290
+ target: { ...continuity.target, service_definition_sha256: effectiveTargetDefinitionDigest } });
10291
+ }
9600
10292
  if (snapshot.existed) {
9601
10293
  migrationStage = "replacement_commit";
9602
10294
  migrationCheck = "atomic_target_render_commit";
@@ -9727,6 +10419,16 @@ async function scheduleOysterunPackageUpdate({
9727
10419
  ? "host_preferences_repair_restart"
9728
10420
  : "host_preferences_update_restart",
9729
10421
  });
10422
+ {
10423
+ const freshBaseline = buildHostContinuityObservation().baseline;
10424
+ const continuity = bindFreshContinuityBaseline(continuityPath, operationState.operation_id,
10425
+ freshBaseline, restartRestore.restart_restore?.restart_id, readHostRestartRestoreState({ configDir: getConfigDir() }));
10426
+ writeContinuityAtomic(continuityPath, { ...continuity,
10427
+ target: { ...continuity.target, service_definition_sha256: retainedPreparationResult.target_service_definition_sha256 || "absent" },
10428
+ retained_files: { ...continuity.retained_files, fresh_restart_state: retainContinuityFile(
10429
+ getHostRestartRestoreStatePath(getConfigDir()), join(continuity.operation_root, "restart-restore-state.pre-restart.json")) },
10430
+ });
10431
+ }
9730
10432
  operationState = recordUpdateLifecycleDiagnostic(operationState, {
9731
10433
  component: "host_coordinator",
9732
10434
  stage: "restart_snapshot",
@@ -9793,6 +10495,12 @@ async function scheduleOysterunPackageUpdate({
9793
10495
  };
9794
10496
  writeRecoveryReconciliationReceipt(resultPath, retainedPreparationResult);
9795
10497
  } catch (handoffError) {
10498
+ // Bind the causal failure before asking another process to recover it;
10499
+ // its recovery_requested control flag is not the incident's first cause.
10500
+ recordContinuityRecovery(continuityPath, operationState.operation_id,
10501
+ Object.assign(handoffError, { lifecycle_diagnostic: handoffError.lifecycle_diagnostic || {
10502
+ stage: migrationStage, check: migrationCheck, result: "failed",
10503
+ } }));
9796
10504
  for (const childEvent of handoffError?.lifecycle_diagnostics || []) {
9797
10505
  if (childEvent.result !== "passed") continue;
9798
10506
  operationState = recordUpdateLifecycleDiagnostic(operationState, {
@@ -9831,6 +10539,7 @@ async function scheduleOysterunPackageUpdate({
9831
10539
  }),
9832
10540
  {
9833
10541
  cwd: REPO_ROOT,
10542
+ detached: true,
9834
10543
  env: buildNodeNpmChildEnvironment(toolchain, {
9835
10544
  ...process.env,
9836
10545
  OYSTERUN_DEBUG_HOST_ARTIFACT_WRITES_ENABLED:
@@ -9859,9 +10568,10 @@ async function scheduleOysterunPackageUpdate({
9859
10568
  ) {
9860
10569
  return;
9861
10570
  }
9862
- const restartLogFd = openSync(logPath, "a");
10571
+ let restartLogFd;
9863
10572
  let child;
9864
10573
  try {
10574
+ restartLogFd = openSync(logPath, "a");
9865
10575
  const restartRunnerArgs = buildRestartRunnerArgs({
9866
10576
  loginGateId: restartLoginGate.gate_id,
9867
10577
  restartId: operationState.restart_id,
@@ -9883,10 +10593,17 @@ async function scheduleOysterunPackageUpdate({
9883
10593
  stdio: ["ignore", restartLogFd, restartLogFd],
9884
10594
  }
9885
10595
  );
10596
+ } catch (error) {
10597
+ recordContinuityRunnerHandoffFailure({ operationId: operationState.operation_id, error, gateId: restartLoginGate.gate_id });
10598
+ return;
9886
10599
  } finally {
9887
- closeSync(restartLogFd);
10600
+ if (restartLogFd !== undefined) closeSync(restartLogFd);
9888
10601
  }
9889
- child.once("error", () => {
10602
+ child.once("error", (error) => {
10603
+ if (operationState.continuity_path) {
10604
+ recordContinuityRunnerHandoffFailure({ operationId: operationState.operation_id, error, gateId: restartLoginGate.gate_id });
10605
+ return;
10606
+ }
9890
10607
  writeUpdateOperationState(
9891
10608
  {
9892
10609
  ...operationState,
@@ -9961,6 +10678,9 @@ async function schedulePreparedLifecycleRecovery({
9961
10678
  if (!["complete_update_and_restart", "restore_and_restart"].includes(kind)) {
9962
10679
  throw new Error("Unsupported prepared Host recovery action.");
9963
10680
  }
10681
+ if (readUpdateOperationState({ configDir: getConfigDir() }).continuity_path) {
10682
+ return scheduleContinuityRecovery({ kind, source });
10683
+ }
9964
10684
  const resolution = maybeCloseSupersededOysterunUpdateRecovery();
9965
10685
  let operationState = readUpdateOperationState({ configDir: getConfigDir() });
9966
10686
  if (
@@ -10221,20 +10941,61 @@ async function schedulePreparedLifecycleRecovery({
10221
10941
  });
10222
10942
  }
10223
10943
 
10224
- function scheduleHostRestart({
10944
+ function scheduleHostRestart(options = {}) {
10945
+ const priorOperation = readUpdateOperationState({ configDir: getConfigDir() });
10946
+ if (priorOperation.continuity_path && priorOperation.transaction_kind === "restart_only" &&
10947
+ priorOperation.status === "restart_recovery_required") {
10948
+ return scheduleContinuityRecovery({ kind: "restart_only", source: options.source || "host_preferences" });
10949
+ }
10950
+ assertNoOysterunUpdateOperationBlocking();
10951
+ let operation = writeUpdateOperationState({
10952
+ operation_id: randomUUID(), attempt_id: randomUUID(), status: "restart_preflight",
10953
+ phase: "Checking restart prerequisites", transaction_kind: "restart_only",
10954
+ source: options.source || "host_preferences", running: true,
10955
+ lifecycle_owner_pid: process.pid, restart_attempted: false,
10956
+ current_version: P233_RUNNING_PACKAGE_VERSION, target_version: P233_RUNNING_PACKAGE_VERSION,
10957
+ }, { configDir: getConfigDir() });
10958
+ try {
10959
+ return scheduleHostRestartPrepared({ ...options, operationId: operation.operation_id });
10960
+ } catch (error) {
10961
+ const retainedPath = continuityReceiptPath(getConfigDir(), operation.operation_id);
10962
+ const retained = existsSync(retainedPath)
10963
+ ? recordContinuityRecovery(retainedPath, operation.operation_id, error) : null;
10964
+ operation = { ...operation, ...readUpdateOperationState({ configDir: getConfigDir() }),
10965
+ continuity_path: retained ? retainedPath : null };
10966
+ operation = recordUpdateRootFailure(operation, {
10967
+ stage: "restart_preflight", check: error.lifecycle_diagnostic?.check || "restart_prerequisites",
10968
+ code: error.code || "restart_preflight_failed", safe_message: sanitizeLifecycleDiagnostic(error.message),
10969
+ observed_at: new Date().toISOString(),
10970
+ });
10971
+ writeUpdateOperationState({ ...operation, status: retained ? "restart_recovery_required" : "restart_rejected", phase: "Restart did not start",
10972
+ running: false, lifecycle_owner_pid: null, restart_attempted: false,
10973
+ terminal_result_status: "restart_rejected", error: sanitizeLifecycleDiagnostic(error.message), error_redacted: true,
10974
+ }, { configDir: getConfigDir() });
10975
+ throw error;
10976
+ }
10977
+ }
10978
+
10979
+ function scheduleHostRestartPrepared({
10225
10980
  redirectUrl = null,
10226
10981
  source = "host_preferences",
10227
10982
  trigger = "host_preferences_restart_host",
10983
+ operationId,
10228
10984
  } = {}) {
10229
- assertNoOysterunUpdateOperationBlocking();
10230
10985
  const { stackName } = assertHostRestartSchedulingAvailable();
10231
10986
  const toolchain = loadActiveHostLifecycleToolchainBundle();
10232
- runCompleteHostLifecyclePreflight({ toolchain });
10987
+ runCompleteHostLifecyclePreflight({ toolchain, allowOperationId: operationId });
10233
10988
  const restartRestore = prepareHostRestartRestore({ trigger });
10234
10989
  const restartHandoff = createHostRestartHandoff();
10235
10990
  const currentVersion = readOysterunPackageVersion();
10991
+ const continuityPath = prepareHostContinuity({ operationId, targetVersion: currentVersion, toolchain, transactionKind: "restart_only",
10992
+ restartState: readHostRestartRestoreState({ configDir: getConfigDir() }) });
10993
+ bindFreshContinuityBaseline(continuityPath, operationId, buildHostContinuityObservation().baseline,
10994
+ restartRestore.restart_restore?.restart_id, readHostRestartRestoreState({ configDir: getConfigDir() }));
10236
10995
  let operationState = writeUpdateOperationState(
10237
10996
  {
10997
+ operation_id: operationId,
10998
+ continuity_path: continuityPath,
10238
10999
  status: "restart_scheduled",
10239
11000
  phase: "Ready to restart",
10240
11001
  running: true,
@@ -10318,9 +11079,10 @@ function scheduleHostRestart({
10318
11079
  "--restart-id",
10319
11080
  operationState.restart_id,
10320
11081
  ];
10321
- const logFd = openSync(logPath, "a");
11082
+ let logFd;
10322
11083
  let child;
10323
11084
  try {
11085
+ logFd = openSync(logPath, "a");
10324
11086
  child = spawn(toolchain.node_path, runnerArgs, {
10325
11087
  cwd: REPO_ROOT,
10326
11088
  detached: true,
@@ -10332,30 +11094,14 @@ function scheduleHostRestart({
10332
11094
  }),
10333
11095
  stdio: ["ignore", logFd, logFd],
10334
11096
  });
11097
+ } catch (error) {
11098
+ recordContinuityRunnerHandoffFailure({ operationId: operationState.operation_id, error, gateId: restartLoginGate.gate_id });
11099
+ return;
10335
11100
  } finally {
10336
- closeSync(logFd);
11101
+ if (logFd !== undefined) closeSync(logFd);
10337
11102
  }
10338
- child.once("error", () => {
10339
- writeUpdateOperationState(
10340
- {
10341
- ...operationState,
10342
- status: "restart_recovery_required",
10343
- phase: "Restart recovery required",
10344
- running: false,
10345
- recovery_required_at: new Date().toISOString(),
10346
- recovery_reason:
10347
- "The canonical restart runner could not start; the current Host state requires bounded recovery.",
10348
- recovery_boundary: "restart_runner_spawn_failed",
10349
- error: "The canonical restart runner could not start.",
10350
- error_redacted: true,
10351
- },
10352
- { configDir: getConfigDir() }
10353
- );
10354
- releaseHostRestartLoginGateSafely({
10355
- gateId: restartLoginGate.gate_id,
10356
- reason: "restart_runner_spawn_failed",
10357
- source: "Restart runner spawn failure",
10358
- });
11103
+ child.once("error", (error) => {
11104
+ recordContinuityRunnerHandoffFailure({ operationId: operationState.operation_id, error, gateId: restartLoginGate.gate_id });
10359
11105
  });
10360
11106
  child.once("exit", (code) => {
10361
11107
  if (code === 0) return;
@@ -10380,10 +11126,69 @@ function scheduleHostRestart({
10380
11126
  };
10381
11127
  }
10382
11128
 
11129
+ function scheduleContinuityRecovery({ kind, source, recheckOnly = false, automatic = false }) {
11130
+ const resolution = recheckOnly || automatic ? null : maybeCloseSupersededOysterunUpdateRecovery();
11131
+ let operation = readUpdateOperationState({ configDir: getConfigDir() });
11132
+ if ((!recheckOnly && !automatic && resolution.recovery_plan?.user_action?.kind !== kind) || operation.running || isProcessRunning(operation.runner_pid)) {
11133
+ throw continuityFailure("recovery", "recovery_changed", "The recovery action no longer matches the retained operation.");
11134
+ }
11135
+ let receipt = readContinuity(operation.continuity_path, operation.operation_id);
11136
+ const toolchain = loadActiveHostLifecycleToolchainBundle();
11137
+ if (!recheckOnly && receipt.transaction_kind !== "restart_only" && !receipt.restart_attempted && !receipt.restart_id &&
11138
+ process.pid === receipt.baseline.host.pid && receipt.operation_root) {
11139
+ // Installation can finish while a newly created provider is still booting.
11140
+ // If no handoff snapshot was ever bound, recover from a fresh complete
11141
+ // live Sessions of the original running coordinator. Never replace a bound one.
11142
+ const fresh = buildHostContinuityObservation().baseline;
11143
+ if (fresh.sessions.some((row) => row.observation_error || !hasCompleteContinuityResumeIdentity(row))) throw continuityFailure("recovery", "fresh_roster_not_ready", "Wait for every current Session provider to be ready before recovering this unprepared handoff.");
11144
+ const prepared = prepareHostRestartRestore({ trigger: "p233_recovery_before_first_handoff_snapshot" });
11145
+ receipt = bindFreshContinuityBaseline(operation.continuity_path, operation.operation_id, fresh,
11146
+ prepared.restart_restore?.restart_id, readHostRestartRestoreState({ configDir: getConfigDir() }));
11147
+ receipt = writeContinuityAtomic(operation.continuity_path, { ...receipt,
11148
+ retained_files: { ...receipt.retained_files, fresh_restart_state: retainContinuityFile(
11149
+ getHostRestartRestoreStatePath(getConfigDir()), join(receipt.operation_root, "restart-restore-state.pre-restart.json")) } });
11150
+ }
11151
+ const runner = resolveOysterunUpdateRestartRunner(toolchain);
11152
+ const handoff = receipt.state === "COMMITTED" || recheckOnly ? {} : createHostRestartHandoff();
11153
+ const logPath = join(getConfigDir(), "operation_logs", `oysterun-continuity-recovery-${operation.operation_id}.log`);
11154
+ operation = writeUpdateOperationState({ ...operation, ...handoff, status: "restart_scheduled",
11155
+ phase: recheckOnly ? "Checking the already-running Host and restored Sessions" : receipt.state === "COMMITTED" ? "Finishing committed cleanup" : "Continuing the retained lifecycle operation",
11156
+ running: true, lifecycle_owner_pid: process.pid, runner_pid: null, requested_action: kind,
11157
+ runner_lease_expires_at: new Date(Date.now() + UPDATE_RESTART_RUNNER_LEASE_MS).toISOString(),
11158
+ operation_log_path: logPath }, { configDir: getConfigDir() });
11159
+ const args = [runner.runnerScript, "--transaction-kind", receipt.transaction_kind === "restart_only" ? "restart_only" : "update_latest_and_restart",
11160
+ "--operation-id", operation.operation_id, "--active-package-root", REPO_ROOT, "--config-dir", getConfigDir(),
11161
+ "--node-bin", toolchain.node_path, "--npm-cli", toolchain.npm_cli_path,
11162
+ "--identity-digest", toolchain.toolchain_identity_digest, "--stack", getActiveStackName(), "--health-url", `http://127.0.0.1:${PORT}/health`,
11163
+ ...(operation.result_path ? ["--result-path", operation.result_path] : []),
11164
+ ...(receipt.restart_id ? ["--restart-id", receipt.restart_id] : []),
11165
+ ...(recheckOnly ? ["--recheck-only", "1"] : kind === "restore_and_restart" ? ["--restore-before-restart", "1"] : []),
11166
+ ...(kind === "restart_only" ? ["--recover-restart", "1"] : []),
11167
+ ];
11168
+ setTimeout(() => {
11169
+ const current = readUpdateOperationState({ configDir: getConfigDir() });
11170
+ if (current.operation_id !== operation.operation_id || current.status !== "restart_scheduled") return;
11171
+ let fd;
11172
+ try {
11173
+ fd = openSync(logPath, "a");
11174
+ const child = spawn(toolchain.node_path, args, { cwd: REPO_ROOT, detached: true,
11175
+ env: buildNodeNpmChildEnvironment(toolchain, { ...process.env, OYSTERUN_LIFECYCLE_OPERATION_ID: operation.operation_id }),
11176
+ stdio: ["ignore", fd, fd] });
11177
+ child.once("error", (error) => recordContinuityRunnerHandoffFailure({ operationId: operation.operation_id, error }));
11178
+ child.unref();
11179
+ } catch (error) {
11180
+ recordContinuityRunnerHandoffFailure({ operationId: operation.operation_id, error });
11181
+ } finally { if (fd !== undefined) closeSync(fd); }
11182
+ }, receipt.state === "COMMITTED" || recheckOnly ? 0 : getHostRestartHandoffDelayMs(handoff.execute_not_before)).unref();
11183
+ return recheckOnly ? null : buildOysterunUpdateStatusPayload({ status: "restart_scheduled", source, ...handoff });
11184
+ }
11185
+
10383
11186
  async function scheduleHostLifecycleTransaction({
10384
11187
  kind,
10385
11188
  source = "host_preferences",
10386
11189
  redirectUrl = null,
11190
+ expectedCurrentVersion = null,
11191
+ expectedTargetVersion = null,
10387
11192
  } = {}) {
10388
11193
  if (kind === "restart_only") {
10389
11194
  return scheduleHostRestart({
@@ -10398,13 +11203,13 @@ async function scheduleHostLifecycleTransaction({
10398
11203
  if (kind === "update_latest_and_restart") {
10399
11204
  return scheduleOysterunPackageUpdate({
10400
11205
  channel: OYSTERUN_UPDATE_RELEASE_CHANNEL,
10401
- source,
11206
+ source, expectedCurrentVersion, expectedTargetVersion,
10402
11207
  });
10403
11208
  }
10404
11209
  if (kind === "update_beta_and_restart") {
10405
11210
  return scheduleOysterunPackageUpdate({
10406
11211
  channel: OYSTERUN_UPDATE_CHANNEL_BETA,
10407
- source,
11212
+ source, expectedCurrentVersion, expectedTargetVersion,
10408
11213
  });
10409
11214
  }
10410
11215
  if (kind === "repair_and_restart") {
@@ -10571,6 +11376,9 @@ function buildHostPreferencesPayload({ managedTunnelQuota = null } = {}) {
10571
11376
  config.notification_sound_web_enabled !== false,
10572
11377
  notification_sound_app_enabled:
10573
11378
  config.notification_sound_app_enabled !== false,
11379
+ restore_previous_sessions_after_restart:
11380
+ config.restore_previous_sessions_after_restart !== false,
11381
+ desired_open_restore: buildDesiredOpenRestoreStatusPayload(),
10574
11382
  daily_telemetry_enabled: config.daily_telemetry_enabled === true,
10575
11383
  daily_telemetry_consent_recorded_at:
10576
11384
  normalizeString(config.daily_telemetry_consent_recorded_at),
@@ -11246,6 +12054,10 @@ function buildHistoryResumeDisposition(record, matrixTranscriptAvailable) {
11246
12054
  resume_identity_kind: resumeIdentityKind,
11247
12055
  resume_unavailable_reason: reason,
11248
12056
  });
12057
+ // agent_folder_missing is disabled because the saved AgentFolder no longer exists.
12058
+ // Recheck on each availability read; a previous restore failure is not a veto.
12059
+ const folder = readAgentFolderAvailability(record.agent_folder);
12060
+ if (!folder.available) return unavailable(folder.reason);
11249
12061
  if (!matrixTranscriptAvailable) {
11250
12062
  return unavailable("matrix_timeline_missing");
11251
12063
  }
@@ -11821,6 +12633,7 @@ function mergeConfigObjects(base = {}, overlay = {}) {
11821
12633
  }
11822
12634
 
11823
12635
  function persistAgentConfigUpdates(folder, body = {}) {
12636
+ requireAgentFolder(folder);
11824
12637
  const layers = readAgentConfigLayers(folder);
11825
12638
  const sharedConfig = readWritableAgentConfig(
11826
12639
  layers.configPath,
@@ -12314,6 +13127,47 @@ function sessionRefResolutionResponseFields(resolution) {
12314
13127
  };
12315
13128
  }
12316
13129
 
13130
+ const historyTranscriptPager = createHistoryTranscriptPager();
13131
+
13132
+ function resolveTranscriptTargetOrRespond({ url, claims, res }) {
13133
+ try {
13134
+ return resolveSessionTranscriptTarget({
13135
+ sessionId: url.searchParams.get("session_id"),
13136
+ sessionRef: url.searchParams.get("session_ref"),
13137
+ requestedAgentId: normalizeString(url.searchParams.get("agent_id")),
13138
+ runtimeOnly: isRuntimeCapabilityOnly(claims),
13139
+ verifyRuntimeActor: () => requireRuntimeCapability(claims, res, { scope: "session:read_transcript" }),
13140
+ verifyLiveTarget: (sessionId, agentId) => requireRuntimeCapability(claims, res, { scope: "session:read_transcript", sessionId, agentId }),
13141
+ resolveLiveRef: (sessionRef) => {
13142
+ if (!sessionManager.getSession(sessionRef) && !sessionManager.list().some((entry) => normalizeSessionName(entry.sessionName) === normalizeSessionName(sessionRef))) {
13143
+ throw Object.assign(new Error("Session reference not found among live Sessions; stopped/history transcript targets require exact --session-id"), { status: 404, reason: "history_session_ref_requires_exact_id" });
13144
+ }
13145
+ return resolveLiveSessionReferenceOrRespond({ sessionRef, res });
13146
+ },
13147
+ getLive: (id) => sessionManager.getSession(id),
13148
+ getHistory: getSessionHistory,
13149
+ authorizeAgent: (id) => requireAgentCapability(claims, id, "can_chat", res),
13150
+ });
13151
+ } catch (error) {
13152
+ respond(res, error.status || 503, { error: error.message, reason: error.reason || "session_history_metadata_unavailable" });
13153
+ return null;
13154
+ }
13155
+ }
13156
+
13157
+ function readTranscriptTarget(target, binding, read) {
13158
+ return target.target_state === "stopped"
13159
+ ? withRouteCMatrixTranscriptReadOnly({ binding }, read)
13160
+ : read();
13161
+ }
13162
+
13163
+ function respondTranscriptSourceError(res, error) {
13164
+ if (!["MATRIX_TRANSCRIPT_STORAGE_UNAVAILABLE", "MATRIX_TRANSCRIPT_ROOM_UNAVAILABLE", "MATRIX_TRANSCRIPT_BINDING_MISMATCH"].includes(error.code)) return false;
13165
+ respond(res, error.code === "MATRIX_TRANSCRIPT_BINDING_MISMATCH" ? 403 : 503, { error: error.message,
13166
+ reason: error.code === "MATRIX_TRANSCRIPT_BINDING_MISMATCH" ? "matrix_transcript_binding_owner_mismatch" : error.code === "MATRIX_TRANSCRIPT_ROOM_UNAVAILABLE" ? "matrix_transcript_room_unavailable" : "matrix_transcript_storage_unavailable",
13167
+ committed_transcript_truth: "matrix_room_timeline", host_db_transcript_product_truth: false });
13168
+ return true;
13169
+ }
13170
+
12317
13171
  function resolveLiveSessionReferenceOrRespond({ sessionId, sessionRef, res }) {
12318
13172
  const normalizedSessionId = normalizeString(sessionId);
12319
13173
  if (normalizedSessionId) {
@@ -14423,6 +15277,8 @@ const ROUTEC_REQUEST_TRAFFIC_SENSITIVE_QUERY_KEYS = [
14423
15277
  const ROUTEC_REQUEST_TRAFFIC_LOG_BATCH_MAX_RECORDS = 50;
14424
15278
  const ROUTEC_REQUEST_TRAFFIC_LOG_FLUSH_DELAY_MS = 250;
14425
15279
  const routeCRequestTrafficLogQueues = new Map();
15280
+ const routeCRequestDelayWindowController =
15281
+ createRouteCRequestDelayWindowController();
14426
15282
 
14427
15283
  function classifyRouteCRequestTrafficEndpoint(method, requestPath) {
14428
15284
  if (requestPath === "/_matrix/client/v3/sync") return "matrix_sync";
@@ -14571,15 +15427,20 @@ function isRouteCChatLivenessDiagnosticsEnabled() {
14571
15427
  return readConfig().debug_routec_chat_liveness_diagnostics_enabled === true;
14572
15428
  }
14573
15429
 
15430
+ function syncRouteCEventLoopDelayWindow(logPath) {
15431
+ return routeCRequestDelayWindowController.sync(logPath);
15432
+ }
15433
+
14574
15434
  function attachRouteCRequestTrafficLogger(req, res, url, requestPath) {
14575
- if (!isRouteCChatLivenessDiagnosticsEnabled()) return;
14576
- const logPath = normalizeString(
14577
- process.env.OYSTERUN_ROUTEC_REQUEST_TRAFFIC_LOG_PATH
14578
- );
14579
- if (!logPath) return;
15435
+ const diagnosticsEnabled = isRouteCChatLivenessDiagnosticsEnabled();
15436
+ const logPath = diagnosticsEnabled
15437
+ ? normalizeString(process.env.OYSTERUN_ROUTEC_REQUEST_TRAFFIC_LOG_PATH)
15438
+ : "";
15439
+ const eventLoopDelayWindow = syncRouteCEventLoopDelayWindow(logPath);
15440
+ if (!eventLoopDelayWindow) return;
14580
15441
 
14581
15442
  const startedAt = new Date();
14582
- const startedMs = Date.now();
15443
+ const startedPerformanceMs = performance.now();
14583
15444
  const startedCpuUsage = process.cpuUsage();
14584
15445
  const startedEventLoopUtilization = performance.eventLoopUtilization();
14585
15446
  const requestId = randomUUID();
@@ -14618,7 +15479,9 @@ function attachRouteCRequestTrafficLogger(req, res, url, requestPath) {
14618
15479
  event,
14619
15480
  started_at: startedAt.toISOString(),
14620
15481
  ended_at: endedAt.toISOString(),
14621
- duration_ms: Date.now() - startedMs,
15482
+ request_elapsed_ms: Number(
15483
+ (performance.now() - startedPerformanceMs).toFixed(3)
15484
+ ),
14622
15485
  method: req.method,
14623
15486
  endpoint,
14624
15487
  path: requestPath,
@@ -14635,6 +15498,8 @@ function attachRouteCRequestTrafficLogger(req, res, url, requestPath) {
14635
15498
  active_ms: Number(eventLoopUtilization.active.toFixed(3)),
14636
15499
  utilization: Number(eventLoopUtilization.utilization.toFixed(6)),
14637
15500
  },
15501
+ event_loop_delay:
15502
+ routeCRequestDelayWindowController.snapshot(eventLoopDelayWindow),
14638
15503
  raw_request_body_logged: false,
14639
15504
  raw_response_body_logged: false,
14640
15505
  stack_name: getActiveStackName(),
@@ -14751,8 +15616,28 @@ const server = createServer(async (req, res) => {
14751
15616
  });
14752
15617
  }
14753
15618
 
14754
- // ── GET /app/sessions/:session_id/chat (serves Route C shell behind clean URL) ──
14755
- if (req.method === "GET" && isCleanSessionChatAppShellPath(path)) {
15619
+ // Shared Sessions/Setup source is mounted by React in the web-chat document.
15620
+ // Missing/stale aliases retain original normalization and warning copy.
15621
+ const legacySessionSurfaceRedirect = req.method === "GET"
15622
+ ? buildLegacySessionSurfaceRedirectLocation(url) : null;
15623
+ if (legacySessionSurfaceRedirect) {
15624
+ const legacySessionId = normalizeString(url.searchParams.get("session_id"));
15625
+ if (sessionManager.getSession(legacySessionId)) {
15626
+ return redirect(res, 303, legacySessionSurfaceRedirect, {
15627
+ "Cache-Control": "no-store",
15628
+ "X-Oysterun-Legacy-Session-Surface": "clean-session-workspace",
15629
+ });
15630
+ }
15631
+ }
15632
+ if (req.method === "GET" && /^\/app\/(?:sessions\/?|session-setup\/?|agent-profile\/?|session-loop\/?|settings\/?|scheduler\/?|terminal\/?|host-preferences\/voice-input-usage\/?)$/.test(path)) {
15633
+ return serveWebChatAppDocument(req, res);
15634
+ }
15635
+
15636
+ if (
15637
+ req.method === "GET" &&
15638
+ (isCleanSessionChatAppShellPath(path) ||
15639
+ isCleanSessionMigratedSurfaceAppShellPath(path))
15640
+ ) {
14756
15641
  const cleanChatClaims = getCleanSessionChatAppShellClaims(req);
14757
15642
  if (!cleanChatClaims) {
14758
15643
  return serveDashboardHtml(req, res, {
@@ -14760,7 +15645,7 @@ const server = createServer(async (req, res) => {
14760
15645
  "X-Oysterun-Clean-Chat-Shell": "dashboard-login",
14761
15646
  });
14762
15647
  }
14763
- const cleanChatSessionId = getCleanSessionChatAppShellSessionId(path);
15648
+ const cleanChatSessionId = getCleanSessionAppShellSessionId(path);
14764
15649
  if (!cleanChatSessionId || !sessionManager.getSession(cleanChatSessionId)) {
14765
15650
  return redirect(
14766
15651
  res,
@@ -14776,11 +15661,43 @@ const server = createServer(async (req, res) => {
14776
15661
  return serveWebChatAppDocument(req, res);
14777
15662
  }
14778
15663
 
15664
+ // ── GET migrated standalone browse utilities (one web-chat document) ──
15665
+ if (req.method === "GET" && isStandaloneMigratedBrowseAppShellPath(path)) {
15666
+ const standaloneBrowseClaims = getCleanSessionChatAppShellClaims(req);
15667
+ if (!standaloneBrowseClaims) {
15668
+ return serveDashboardHtml(req, res, {
15669
+ "X-Oysterun-Standalone-Browse-Auth-Guard": "dashboard-login-required",
15670
+ "X-Oysterun-Standalone-Browse-Shell": "dashboard-login",
15671
+ });
15672
+ }
15673
+ return serveWebChatAppDocument(req, res);
15674
+ }
15675
+
15676
+ // ── GET standalone migrated product surfaces (one Route C document) ──
15677
+ if (req.method === "GET" && isStandaloneMigratedSurfaceAppShellPath(path)) {
15678
+ const standaloneClaims = getCleanSessionChatAppShellClaims(req);
15679
+ if (!standaloneClaims) {
15680
+ return serveDashboardHtml(req, res, {
15681
+ "X-Oysterun-Standalone-Surface-Auth-Guard": "dashboard-login-required",
15682
+ "X-Oysterun-Standalone-Surface-Shell": "dashboard-login",
15683
+ });
15684
+ }
15685
+ return serveWebChatAppDocument(req, res);
15686
+ }
15687
+
14779
15688
  // ── GET /.well-known/matrix/client (exact public Route C Host-origin discovery only) ──
14780
15689
  if (req.method === "GET" && path === "/.well-known/matrix/client") {
14781
15690
  return serveRouteCWellKnownMatrixClient(req, res);
14782
15691
  }
14783
15692
 
15693
+ // /app is the stable alias for the new frontend Sessions list.
15694
+ if (req.method === "GET" && (path === "/app" || path === "/app/")) {
15695
+ return redirect(res, 308, `/app/sessions${url.search}`, {
15696
+ "Cache-Control": "no-store",
15697
+ "X-Oysterun-Sessions-Root-Alias": "app-sessions",
15698
+ });
15699
+ }
15700
+
14784
15701
  // ── GET /app* (serves dashboard HTML, no auth — login page is in the HTML) ──
14785
15702
  if (
14786
15703
  req.method === "GET" &&
@@ -15105,15 +16022,23 @@ const server = createServer(async (req, res) => {
15105
16022
  });
15106
16023
  }
15107
16024
 
15108
- // ── POST /routec/matrix/host-scoped-cinny-session-bootstrap (existing Host session only; no auth credentials) ──
16025
+ // ── POST /routec/matrix/host-scoped-cinny-session-bootstrap (authenticated exact target) ──
15109
16026
  if (
15110
16027
  req.method === "POST" &&
15111
16028
  path === "/routec/matrix/host-scoped-cinny-session-bootstrap"
15112
16029
  ) {
16030
+ const claims = authenticate(req, res);
16031
+ if (!claims) return;
16032
+ // Runtime capability markers are verified by their owning capability
16033
+ // routes; they are not a login identity for Matrix credential issuance.
16034
+ if (claims._runtimeCapabilityAuth) {
16035
+ return respond(res, 401, { error: "Authentication required" });
16036
+ }
15113
16037
  const body = await readBody(req);
15114
16038
  return await routeCMatrixFacade.handleHostScopedCinnySessionBootstrap({
15115
16039
  req,
15116
16040
  res,
16041
+ claims,
15117
16042
  body,
15118
16043
  respond,
15119
16044
  });
@@ -15377,6 +16302,27 @@ const server = createServer(async (req, res) => {
15377
16302
  }
15378
16303
  }
15379
16304
 
16305
+ if (req.method === "GET" && path === "/admin/lifecycle-qualification") {
16306
+ const operationId = url.searchParams.get("operation_id");
16307
+ const bearer = String(req.headers.authorization || "").replace(/^Bearer /, "");
16308
+ if (!verifyContinuityProbeGrant(getConfigDir(), operationId, bearer)) {
16309
+ return respond(res, 403, { error: "Operation observation grant required" });
16310
+ }
16311
+ try {
16312
+ const receipt = readContinuity(continuityReceiptPath(getConfigDir(), operationId), operationId);
16313
+ const observation = buildHostContinuityObservation(receipt.baseline);
16314
+ res.setHeader("Cache-Control", "no-store");
16315
+ return respond(res, 200, {
16316
+ operation_id: operationId, request_nonce: url.searchParams.get("nonce"),
16317
+ ...observation, baseline: undefined,
16318
+ });
16319
+ } catch (error) {
16320
+ return respond(res, 409, { operation_id: operationId, request_nonce: url.searchParams.get("nonce"),
16321
+ error: sanitizeLifecycleDiagnostic(error.message), code: error.code || "continuity_observation_failed",
16322
+ lifecycle_diagnostic: error.lifecycle_diagnostic || null });
16323
+ }
16324
+ }
16325
+
15380
16326
  if (req.method === "POST" && path === "/admin/restart-prepare") {
15381
16327
  const serviceControlClaims = authenticateLocalServiceControlRequest(req);
15382
16328
  let restartPrepareClaims = serviceControlClaims;
@@ -15433,6 +16379,8 @@ const server = createServer(async (req, res) => {
15433
16379
  await scheduleHostLifecycleTransaction({
15434
16380
  kind: normalizeString(body.kind),
15435
16381
  source: serviceControlClaims ? "terminal" : "host_preferences",
16382
+ expectedCurrentVersion: body.expected_current_version,
16383
+ expectedTargetVersion: body.expected_target_version,
15436
16384
  redirectUrl,
15437
16385
  })
15438
16386
  );
@@ -16154,7 +17102,10 @@ const server = createServer(async (req, res) => {
16154
17102
  })
16155
17103
  );
16156
17104
  } catch (err) {
16157
- return respond(res, 409, { error: err.message, code: err.code || null });
17105
+ return respond(res, 409, {
17106
+ ...buildOysterunUpdateStatusPayload(),
17107
+ error: sanitizeLifecycleDiagnostic(err.message), code: err.code || null,
17108
+ });
16158
17109
  }
16159
17110
  }
16160
17111
 
@@ -16197,6 +17148,8 @@ const server = createServer(async (req, res) => {
16197
17148
  await scheduleHostLifecycleTransaction({
16198
17149
  kind: "update_latest_and_restart",
16199
17150
  source: "host_preferences",
17151
+ expectedCurrentVersion: body.expected_current_version,
17152
+ expectedTargetVersion: body.expected_target_version,
16200
17153
  })
16201
17154
  );
16202
17155
  } catch (err) {
@@ -16221,13 +17174,15 @@ const server = createServer(async (req, res) => {
16221
17174
  if (req.method === "POST" && path === "/admin/update-beta-run") {
16222
17175
  if (!requireDashboardMode(claims, res)) return;
16223
17176
  try {
16224
- await readJsonBody(req);
17177
+ const body = await readJsonBody(req);
16225
17178
  return respond(
16226
17179
  res,
16227
17180
  202,
16228
17181
  await scheduleHostLifecycleTransaction({
16229
17182
  kind: "update_beta_and_restart",
16230
17183
  source: "host_preferences",
17184
+ expectedCurrentVersion: body.expected_current_version,
17185
+ expectedTargetVersion: body.expected_target_version,
16231
17186
  })
16232
17187
  );
16233
17188
  } catch (err) {
@@ -17562,6 +18517,57 @@ const server = createServer(async (req, res) => {
17562
18517
  }
17563
18518
  }
17564
18519
 
18520
+ if (req.method === "POST" && path === "/sessions/restore-previous") {
18521
+ if (!requireDashboardMode(claims, res)) return;
18522
+ try {
18523
+ const rawBody = await readRawBody(req);
18524
+ let body = {};
18525
+ if (rawBody.length > 0) {
18526
+ try {
18527
+ body = JSON.parse(rawBody.toString());
18528
+ } catch {
18529
+ return respond(res, 400, { error: "Invalid JSON body" });
18530
+ }
18531
+ }
18532
+ const hasSessionId = Object.prototype.hasOwnProperty.call(
18533
+ body,
18534
+ "session_id"
18535
+ );
18536
+ const sessionId = normalizeString(body.session_id);
18537
+ if (hasSessionId && !sessionId) {
18538
+ return respond(res, 400, { error: "session_id must be a string" });
18539
+ }
18540
+ if (sessionId) {
18541
+ const desired = sessionManager.getDesiredOpenRestoreStatus();
18542
+ if (
18543
+ !desired.valid ||
18544
+ !desired.sessions.some((entry) => entry.session_id === sessionId)
18545
+ ) {
18546
+ return respond(res, 404, {
18547
+ error: "Desired-open Session not found",
18548
+ session_id: sessionId,
18549
+ });
18550
+ }
18551
+ }
18552
+ const restore = await reconcileDesiredOpenSessions({
18553
+ trigger: sessionId
18554
+ ? "manual_restore_previous_session"
18555
+ : "manual_restore_previous_sessions",
18556
+ sessionIds: sessionId ? [sessionId] : null,
18557
+ });
18558
+ return respond(res, 200, {
18559
+ status: "restore_previous_sessions_complete",
18560
+ session_id: sessionId,
18561
+ restore,
18562
+ });
18563
+ } catch (err) {
18564
+ return respond(res, 400, {
18565
+ error: err.message,
18566
+ restore: buildDesiredOpenRestoreStatusPayload(),
18567
+ });
18568
+ }
18569
+ }
18570
+
17565
18571
  if (req.method === "POST" && path === "/telemetry/feature") {
17566
18572
  if (!requireDashboardMode(claims, res)) return;
17567
18573
  try {
@@ -17602,6 +18608,10 @@ const server = createServer(async (req, res) => {
17602
18608
  body,
17603
18609
  "daily_telemetry_enabled"
17604
18610
  ) &&
18611
+ !Object.prototype.hasOwnProperty.call(
18612
+ body,
18613
+ "restore_previous_sessions_after_restart"
18614
+ ) &&
17605
18615
  !Object.prototype.hasOwnProperty.call(body, "default_browse_path") &&
17606
18616
  !Object.prototype.hasOwnProperty.call(body, "public_base_url") &&
17607
18617
  !Object.prototype.hasOwnProperty.call(body, "direct_host_url") &&
@@ -17613,7 +18623,7 @@ const server = createServer(async (req, res) => {
17613
18623
  ) {
17614
18624
  return respond(res, 400, {
17615
18625
  error:
17616
- "display_name, notification_sound_web_enabled, notification_sound_app_enabled, daily_telemetry_enabled, default_browse_path, direct_host_url, claude_command, codex_command, grok_command, session_defaults, or voice_input required",
18626
+ "display_name, notification_sound_web_enabled, notification_sound_app_enabled, restore_previous_sessions_after_restart, daily_telemetry_enabled, default_browse_path, direct_host_url, claude_command, codex_command, grok_command, session_defaults, or voice_input required",
17617
18627
  });
17618
18628
  }
17619
18629
 
@@ -17697,6 +18707,24 @@ const server = createServer(async (req, res) => {
17697
18707
  }
17698
18708
  }
17699
18709
 
18710
+ if (
18711
+ Object.prototype.hasOwnProperty.call(
18712
+ body,
18713
+ "restore_previous_sessions_after_restart"
18714
+ )
18715
+ ) {
18716
+ if (
18717
+ typeof body.restore_previous_sessions_after_restart !== "boolean"
18718
+ ) {
18719
+ return respond(res, 400, {
18720
+ error:
18721
+ "restore_previous_sessions_after_restart must be a boolean",
18722
+ });
18723
+ }
18724
+ updates.restore_previous_sessions_after_restart =
18725
+ body.restore_previous_sessions_after_restart;
18726
+ }
18727
+
17700
18728
  if (Object.prototype.hasOwnProperty.call(body, "voice_input")) {
17701
18729
  const requestedVoiceInput = body.voice_input;
17702
18730
  if (
@@ -18031,6 +19059,20 @@ const server = createServer(async (req, res) => {
18031
19059
  }
18032
19060
  }
18033
19061
 
19062
+ if ((req.method === 'GET' && path === '/session/voice-input/receipt') ||
19063
+ (req.method === 'POST' && path === '/session/voice-input/release')) {
19064
+ const body = req.method === 'POST' ? await readJsonBody(req) : null;
19065
+ const expectedHost = normalizeString(body?.host_identity || url.searchParams.get('host_identity'));
19066
+ if (expectedHost && expectedHost !== readCloudIdentity()?.host_id) return respond(res,409,{error:'Recording belongs to a different Host.',code:'voice_input_host_mismatch'});
19067
+ const sessionId = normalizeString(body?.session_id || url.searchParams.get('session_id'));
19068
+ const target = sessionManager.getSession(sessionId) || getSessionHistory().find(row => row.session_id === sessionId);
19069
+ if (!target) return respond(res,404,{error:'Session not found'});
19070
+ if (!requireAgentCapability(claims, target.agentId || target.agent_id, 'can_chat', res)) return;
19071
+ const captureId = normalizeString(body?.capture_id || url.searchParams.get('capture_id'));
19072
+ if (!captureId || !/^[a-zA-Z0-9_-]{1,100}$/.test(captureId)) return respond(res,400,{error:'Invalid recording identity.'});
19073
+ return respond(res,200,req.method === 'POST' ? voiceInputReceipts.release(sessionId,captureId) : voiceInputReceipts.get(sessionId,captureId));
19074
+ }
19075
+
18034
19076
  if (
18035
19077
  req.method === "POST" &&
18036
19078
  path === "/session/voice-input/transcribe"
@@ -18038,9 +19080,13 @@ const server = createServer(async (req, res) => {
18038
19080
  let sessionId = null;
18039
19081
  let singleFlightStarted = false;
18040
19082
  let ownerCaptureBase = null;
19083
+ let receiptCaptureId = null;
19084
+ let receiptGeneration = 1;
19085
+ let providerStarted = false;
18041
19086
  try {
18042
19087
  const { fields, file } = await parseVoiceInputMultipart(req);
18043
19088
  sessionId = normalizeString(fields.session_id);
19089
+ if (fields.host_identity && fields.host_identity !== readCloudIdentity()?.host_id) return respond(res,409,{error:'Recording belongs to a different Host.',code:'voice_input_host_mismatch'});
18044
19090
  const session = requireSessionCapability(
18045
19091
  claims,
18046
19092
  sessionId,
@@ -18058,13 +19104,6 @@ const server = createServer(async (req, res) => {
18058
19104
  code: "voice_input_unavailable",
18059
19105
  });
18060
19106
  }
18061
- if (!voiceInputSingleFlight.begin(sessionId)) {
18062
- return respond(res, 409, {
18063
- error: "Voice Input transcription is already in progress",
18064
- code: "voice_input_in_flight",
18065
- });
18066
- }
18067
- singleFlightStarted = true;
18068
19107
  const metadata = validateVoiceInputMetadata({
18069
19108
  captureId: fields.capture_id,
18070
19109
  recordingId: fields.recording_id,
@@ -18096,6 +19135,17 @@ const server = createServer(async (req, res) => {
18096
19135
  code: "voice_input_sha_mismatch",
18097
19136
  });
18098
19137
  }
19138
+ receiptCaptureId = metadata.captureId;
19139
+ receiptGeneration = Number(fields.attempt_generation || 1);
19140
+ const receipt = voiceInputReceipts.begin(sessionId, receiptCaptureId,
19141
+ voiceInputFingerprint({ ...metadata, mimeType: resolvedFile.mimeType, size: file.audio.length, audioSha256: actualSha }),
19142
+ receiptGeneration, fields.explicit_retry === 'true', { recording_id: metadata.recordingId, segment_index: metadata.segmentIndex, duration_ms: metadata.durationMs, normalized_mime: resolvedFile.mimeType.split(';')[0].toLowerCase(), size_bytes: file.audio.length, audio_sha256: actualSha });
19143
+ if (receipt.state === 'succeeded') return respond(res, 200, receipt.result);
19144
+ if (receipt.state !== 'received') return respond(res, 409, { ...receipt, error: receipt.result?.error || 'The recording attempt is not ready to upload.', code: `voice_input_${receipt.state}` });
19145
+ if (!voiceInputSingleFlight.begin(sessionId)) return respond(res,409,{error:'Voice Input transcription is already in progress',code:'voice_input_in_flight'});
19146
+ singleFlightStarted = true;
19147
+ if (!voiceInputReceipts.start(sessionId, receiptCaptureId, receiptGeneration)) return respond(res,409,{error:'Recording was removed or already started.',code:'voice_input_receipt_changed'});
19148
+ providerStarted = true;
18099
19149
  const privateSettings = readVoiceInputPrivateSettings(getConfigDir());
18100
19150
  if (privateSettings.owner_capture_enabled) {
18101
19151
  let recentContext = [];
@@ -18156,14 +19206,20 @@ const server = createServer(async (req, res) => {
18156
19206
  }
18157
19207
  : null
18158
19208
  );
18159
- return respond(res, 200, {
19209
+ const response = {
18160
19210
  status: "voice_input_transcribed",
18161
19211
  session_id: session.id,
18162
19212
  capture_id: metadata.captureId,
18163
19213
  audio_sha256: actualSha,
18164
19214
  ...result,
18165
- });
19215
+ attempt_generation: receiptGeneration,
19216
+ };
19217
+ if (!voiceInputReceipts.complete(sessionId, receiptCaptureId, receiptGeneration, 'succeeded', response)) return respond(res,409,{error:'Recording was removed.',code:'voice_input_released'});
19218
+ return respond(res,200,response);
18166
19219
  } catch (err) {
19220
+ if (providerStarted) voiceInputReceipts.complete(sessionId, receiptCaptureId, receiptGeneration,
19221
+ err.providerStatus ? (err.providerStatus >= 400 && err.providerStatus < 500 && err.providerStatus !== 429 ? 'failed_terminal' : 'failed_retryable') : 'outcome_unknown',
19222
+ { error: err.message, code: err.code || 'voice_input_transcription_failed' });
18167
19223
  await uploadOwnerVoiceInputCaptureBestEffort(
18168
19224
  ownerCaptureBase
18169
19225
  ? {
@@ -18180,7 +19236,7 @@ const server = createServer(async (req, res) => {
18180
19236
  );
18181
19237
  return respond(
18182
19238
  res,
18183
- err.code === "voice_input_provider_timeout" ? 504 : 400,
19239
+ err.code === "voice_input_provider_timeout" ? 504 : String(err.code).includes("receipt") || String(err.code).includes("attempt") || err.code === "voice_input_released" ? 409 : 400,
18184
19240
  {
18185
19241
  error: err.message,
18186
19242
  code: err.code || "voice_input_transcription_failed",
@@ -18471,7 +19527,7 @@ const server = createServer(async (req, res) => {
18471
19527
  });
18472
19528
  } catch (err) {
18473
19529
  return respond(res, 404, {
18474
- error: err.message,
19530
+ ...agentFolderErrorResponse(err),
18475
19531
  });
18476
19532
  }
18477
19533
 
@@ -18546,7 +19602,7 @@ const server = createServer(async (req, res) => {
18546
19602
  }
18547
19603
  } catch (err) {
18548
19604
  return respond(res, 400, {
18549
- error: err.message,
19605
+ ...agentFolderErrorResponse(err, folder),
18550
19606
  config_mutated: false,
18551
19607
  });
18552
19608
  }
@@ -18899,6 +19955,53 @@ const server = createServer(async (req, res) => {
18899
19955
  }
18900
19956
  }
18901
19957
 
19958
+ const sessionLoopRecoveryMatch = path.match(
19959
+ /^\/session\/loops\/([^/]+)\/recovery$/
19960
+ );
19961
+ if (sessionLoopRecoveryMatch && req.method === "POST") {
19962
+ const scheduleId = decodeURIComponent(sessionLoopRecoveryMatch[1]);
19963
+ const body = await readBody(req);
19964
+ const sessionTarget = resolveLiveSessionReferenceOrRespond({
19965
+ sessionId: body.session_id,
19966
+ sessionRef: body.session_ref,
19967
+ res,
19968
+ });
19969
+ if (!sessionTarget) return;
19970
+ const session = requireSessionCapabilityOrRuntimeScope(
19971
+ claims,
19972
+ sessionTarget.sessionId,
19973
+ "can_chat",
19974
+ "session:send",
19975
+ res
19976
+ );
19977
+ if (!session) return;
19978
+ try {
19979
+ const result = schedulerService.resolveSessionLoopIncidentForGui({
19980
+ hostSessionId: session.id,
19981
+ agentId: session.agentId,
19982
+ agentFolder: session.cwd,
19983
+ scheduleId,
19984
+ action: body.action,
19985
+ });
19986
+ const feedback = await writeLoopFeedbackOrThrow({
19987
+ session,
19988
+ action: `recovery ${result.action}`,
19989
+ schedule: result.schedule,
19990
+ });
19991
+ return respond(res, 200, {
19992
+ ...result,
19993
+ matrix_feedback: feedback,
19994
+ ...sessionRefResolutionResponseFields(sessionTarget.resolution),
19995
+ });
19996
+ } catch (err) {
19997
+ return respond(
19998
+ res,
19999
+ getSessionLoopCrudErrorStatus(err),
20000
+ serializeSessionLoopCrudError(err)
20001
+ );
20002
+ }
20003
+ }
20004
+
18902
20005
  const sessionLoopMatch = path.match(/^\/session\/loops\/([^/]+)$/);
18903
20006
  if (
18904
20007
  sessionLoopMatch &&
@@ -18974,6 +20077,17 @@ const server = createServer(async (req, res) => {
18974
20077
  ...sessionRefResolutionResponseFields(sessionTarget.resolution),
18975
20078
  });
18976
20079
  }
20080
+ const definitionFieldsPresent = [
20081
+ "interval",
20082
+ "command_text",
20083
+ "prompt_text",
20084
+ "prompt",
20085
+ "start_at",
20086
+ "end_at",
20087
+ ].some((key) => Object.prototype.hasOwnProperty.call(body, key));
20088
+ const runtimeOnly =
20089
+ body.runtime_only === true ||
20090
+ (typeof desiredEnabled === "boolean" && !definitionFieldsPresent);
18977
20091
  const result = schedulerService.updateSessionLoopScheduleForGui({
18978
20092
  hostSessionId: session.id,
18979
20093
  agentId: session.agentId,
@@ -18984,7 +20098,7 @@ const server = createServer(async (req, res) => {
18984
20098
  startAt: body.start_at ?? null,
18985
20099
  endAt: body.end_at ?? null,
18986
20100
  enabled: desiredEnabled,
18987
- runtimeOnly: body.runtime_only === true,
20101
+ runtimeOnly,
18988
20102
  });
18989
20103
  const feedback = await writeLoopFeedbackOrThrow({
18990
20104
  session,
@@ -19335,6 +20449,7 @@ const server = createServer(async (req, res) => {
19335
20449
 
19336
20450
  // ── POST /session/start ────────────────────────────────
19337
20451
  if (req.method === "POST" && path === "/session/start") {
20452
+ try { assertSessionRosterMutationAvailable(); } catch (error) { return respond(res, 409, { error: error.message, code: error.code }); }
19338
20453
  const body = await readBody(req);
19339
20454
  if (rejectRetiredCapabilitySettingInput(res, body)) return;
19340
20455
  const { agent_id, agent_folder, interface_type, session_name } = body;
@@ -19350,6 +20465,12 @@ const server = createServer(async (req, res) => {
19350
20465
  res
19351
20466
  );
19352
20467
  if (!auth) return;
20468
+ let sessionTags;
20469
+ try {
20470
+ sessionTags = resolveSessionStartTags(body, auth);
20471
+ } catch (err) {
20472
+ return respond(res, 400, { error: err.message, code: err.code });
20473
+ }
19353
20474
 
19354
20475
  let resolvedFolder;
19355
20476
  try {
@@ -19555,6 +20676,7 @@ const server = createServer(async (req, res) => {
19555
20676
  }
19556
20677
  let session;
19557
20678
  try {
20679
+ assertSessionRosterMutationAvailable();
19558
20680
  session = sessionManager.start({
19559
20681
  agentId: agent_id,
19560
20682
  cwd: resolvedFolder,
@@ -19565,6 +20687,9 @@ const server = createServer(async (req, res) => {
19565
20687
  providerStartupResolvedCommand:
19566
20688
  startupPreflight.diagnostic?.resolved_command || null,
19567
20689
  sessionName: resolvedSessionName,
20690
+ tags: sessionTags,
20691
+ parentSessionId: auth.actorSession?.id || null,
20692
+ deferTagsPersistence: true,
19568
20693
  provider: resolved.runtime.provider,
19569
20694
  model: resolved.runtime.model,
19570
20695
  reasoningEffort: resolved.runtime.reasoningEffort,
@@ -19720,11 +20845,13 @@ const server = createServer(async (req, res) => {
19720
20845
  recordTelemetryCounter("session_started_count");
19721
20846
  recordTelemetryFeature("sessions");
19722
20847
  recordTelemetryFeature("chat");
20848
+ const persistedTags = canonicalSessionTags(session);
19723
20849
 
19724
- return respond(res, 200, {
20850
+ const response = {
19725
20851
  success: true,
19726
20852
  session_id: session.id,
19727
20853
  session_name: session.sessionName,
20854
+ tags: persistedTags,
19728
20855
  agent_id,
19729
20856
  provider: session.provider || "claude",
19730
20857
  permission_mode: session.permissionMode ?? null,
@@ -19800,7 +20927,10 @@ const server = createServer(async (req, res) => {
19800
20927
  bookkeeping_warning: bookkeepingWarnings.length
19801
20928
  ? bookkeepingWarnings.join("; ")
19802
20929
  : null,
19803
- });
20930
+ };
20931
+ commitSuccessfulSessionTags(session);
20932
+ refreshSessionBookkeepingWarning(response, session);
20933
+ return respond(res, 200, response);
19804
20934
  }
19805
20935
 
19806
20936
  // ── P203 callback get/list/cancel ──────────────────────
@@ -19859,7 +20989,7 @@ const server = createServer(async (req, res) => {
19859
20989
  });
19860
20990
  return respond(res, 200, { status: "callbacks", ...page });
19861
20991
  }
19862
- const receipt = callbackHostIntegration.cancel(
20992
+ const receipt = await callbackHostIntegration.cancel(
19863
20993
  callbackBody?.callback_id,
19864
20994
  { relatedSessionId }
19865
20995
  );
@@ -20596,6 +21726,7 @@ const server = createServer(async (req, res) => {
20596
21726
 
20597
21727
  // ── POST /session/stop ─────────────────────────────────
20598
21728
  if (req.method === "POST" && path === "/session/stop") {
21729
+ try { assertSessionRosterMutationAvailable(); } catch (error) { return respond(res, 409, { error: error.message, code: error.code }); }
20599
21730
  const body = await readBody(req);
20600
21731
  const { force } = body;
20601
21732
  const sessionTarget = resolveLiveSessionReferenceOrRespond({
@@ -20654,7 +21785,9 @@ const server = createServer(async (req, res) => {
20654
21785
  try {
20655
21786
  if (force) {
20656
21787
  await sessionManager.killSession(session.id);
21788
+ sessionManager.removeDesiredOpenSession(session.id);
20657
21789
  } else {
21790
+ assertSessionRosterMutationAvailable();
20658
21791
  await sessionManager.stopSession(session.id);
20659
21792
  }
20660
21793
  sessionLoopRuntimeCleanup =
@@ -20712,6 +21845,8 @@ const server = createServer(async (req, res) => {
20712
21845
 
20713
21846
  recordTelemetryCounter("session_stopped_count");
20714
21847
  recordTelemetryFeature("sessions");
21848
+ sessionCollectionAdmittedSessionIds.delete(session.id);
21849
+ commitSessionCollectionChange(["stop", "history"], [session.id]);
20715
21850
  return respond(res, 200, {
20716
21851
  status: force ? "killed" : "stopped",
20717
21852
  session_id: session.id,
@@ -20759,6 +21894,9 @@ const server = createServer(async (req, res) => {
20759
21894
  }
20760
21895
  const { session: renamedSession, changed } =
20761
21896
  sessionManager.renameSession(session.id, resolvedSessionName);
21897
+ if (changed) {
21898
+ commitSessionCollectionChange(["rename"], [renamedSession.id]);
21899
+ }
20762
21900
  return respond(res, 200, {
20763
21901
  renamed: changed,
20764
21902
  session_id: renamedSession.id,
@@ -21020,7 +22158,7 @@ const server = createServer(async (req, res) => {
21020
22158
  );
21021
22159
  if (!session) return;
21022
22160
 
21023
- const message = sessionManager.cancelOutboxMessage(
22161
+ const message = await sessionManager.cancelOutboxMessage(
21024
22162
  session.id,
21025
22163
  message_id
21026
22164
  );
@@ -21128,6 +22266,7 @@ const server = createServer(async (req, res) => {
21128
22266
 
21129
22267
  // ── POST /session/restart ──────────────────────────────
21130
22268
  if (req.method === "POST" && path === "/session/restart") {
22269
+ try { assertSessionRosterMutationAvailable(); } catch (error) { return respond(res, 409, { error: error.message, code: error.code }); }
21131
22270
  const body = await readBody(req);
21132
22271
  const {
21133
22272
  provider,
@@ -21316,6 +22455,7 @@ const server = createServer(async (req, res) => {
21316
22455
 
21317
22456
  let restarted;
21318
22457
  try {
22458
+ assertSessionRosterMutationAvailable();
21319
22459
  restarted = await sessionManager.restartSession(
21320
22460
  currentSession.id,
21321
22461
  overrides
@@ -21364,9 +22504,11 @@ const server = createServer(async (req, res) => {
21364
22504
  recordTelemetryCounter("session_restarted_count");
21365
22505
  recordTelemetryFeature("sessions");
21366
22506
  recordTelemetryFeature("chat");
22507
+ admitSessionCollectionSession(restarted.id, ["restore"], { required: true });
21367
22508
  return respond(res, 200, {
21368
22509
  session_id: restarted.id,
21369
22510
  session_name: restarted.sessionName || null,
22511
+ tags: canonicalSessionTags(restarted),
21370
22512
  agent_id: restarted.agentId,
21371
22513
  provider: restarted.provider || "claude",
21372
22514
  model: restarted.model || null,
@@ -21470,6 +22612,7 @@ const server = createServer(async (req, res) => {
21470
22612
  agent_id: session.agentId,
21471
22613
  session_id: session.id,
21472
22614
  session_name: session.sessionName || null,
22615
+ tags: canonicalSessionTags(session),
21473
22616
  provider: session.provider || "claude",
21474
22617
  model: session.model || null,
21475
22618
  reasoning_effort: session.reasoningEffort || null,
@@ -21540,6 +22683,13 @@ const server = createServer(async (req, res) => {
21540
22683
  const hasNotificationRequest = hasNotificationConfigRequest(body);
21541
22684
  const hasAllowedPathsRequest =
21542
22685
  hasSessionProfileAllowedPathsRequest(body);
22686
+ const hasTagsRequest = Object.prototype.hasOwnProperty.call(
22687
+ body,
22688
+ "tags"
22689
+ );
22690
+ const nextTags = hasTagsRequest
22691
+ ? normalizeSessionTags(body.tags)
22692
+ : null;
21543
22693
  const telegramConfigRequest = normalizeTelegramConfigPayload(body);
21544
22694
  const hasTelegramRequest = hasTelegramConfigUpdate(telegramConfigRequest);
21545
22695
  if (
@@ -21548,10 +22698,11 @@ const server = createServer(async (req, res) => {
21548
22698
  !hasWebsitePasswordRequest &&
21549
22699
  !hasNotificationRequest &&
21550
22700
  !hasAllowedPathsRequest &&
22701
+ !hasTagsRequest &&
21551
22702
  !hasTelegramRequest
21552
22703
  ) {
21553
22704
  throw new Error(
21554
- "web_access, web_enabled, web_password, notifications, allowed_paths, or telegram settings required"
22705
+ "web_access, web_enabled, web_password, notifications, allowed_paths, tags, or telegram settings required"
21555
22706
  );
21556
22707
  }
21557
22708
  const persistSharedConfig =
@@ -21692,7 +22843,10 @@ const server = createServer(async (req, res) => {
21692
22843
  session.notificationConfigUpdatedAt = new Date().toISOString();
21693
22844
  }
21694
22845
  if (hasAllowedPathsRequest) {
21695
- session.workspacePolicy = resolved.runtime.workspacePolicy;
22846
+ sessionManager.updateWorkspacePolicy(
22847
+ session.id,
22848
+ resolved.runtime.workspacePolicy
22849
+ );
21696
22850
  }
21697
22851
  if (hasTelegramRequest) {
21698
22852
  session.telegramConfig = buildResolvedSessionTelegramConfig(
@@ -21714,7 +22868,10 @@ const server = createServer(async (req, res) => {
21714
22868
  });
21715
22869
  }
21716
22870
  if (hasAllowedPathsRequest) {
21717
- session.workspacePolicy = nextWorkspacePolicy;
22871
+ sessionManager.updateWorkspacePolicy(
22872
+ session.id,
22873
+ nextWorkspacePolicy
22874
+ );
21718
22875
  }
21719
22876
  applySessionTelegramConfigRequest(session, telegramConfigRequest);
21720
22877
  if (hasTelegramRequest) {
@@ -21747,13 +22904,14 @@ const server = createServer(async (req, res) => {
21747
22904
  if (hasTelegramRequest) {
21748
22905
  recordTelemetryFeature("telegram_settings");
21749
22906
  }
21750
- return respond(res, 200, {
22907
+ const response = {
21751
22908
  status: "session_profile_config_updated",
21752
22909
  session_id: session.id,
21753
22910
  agent_id: session.agentId,
21754
22911
  provider: session.provider || "claude",
21755
22912
  model: session.model || null,
21756
22913
  cwd: session.cwd || null,
22914
+ tags: hasTagsRequest ? nextTags : canonicalSessionTags(session),
21757
22915
  web_access: nextWebAccess,
21758
22916
  web_enabled: nextWebEnabled,
21759
22917
  website,
@@ -21791,10 +22949,13 @@ const server = createServer(async (req, res) => {
21791
22949
  private_config_mutated: localConfigMutated,
21792
22950
  private_local_material_serialized: localConfigMutated,
21793
22951
  ...sessionRefResolutionResponseFields(sessionTarget.resolution),
21794
- });
22952
+ };
22953
+ if (hasTagsRequest) saveSuccessfulSessionTags(session, nextTags);
22954
+ return respond(res, 200, response);
21795
22955
  } catch (err) {
21796
22956
  return respond(res, 400, {
21797
22957
  error: err.message,
22958
+ code: err.code || "session_profile_config_invalid",
21798
22959
  session_profile_config: true,
21799
22960
  config_mutated: false,
21800
22961
  });
@@ -22052,36 +23213,11 @@ const server = createServer(async (req, res) => {
22052
23213
 
22053
23214
  // ── GET /session/messages/search?session_id=…&q=… ──
22054
23215
  if (req.method === "GET" && path === "/session/messages/search") {
22055
- const sessionTarget = resolveLiveSessionReferenceOrRespond({
22056
- sessionId: url.searchParams.get("session_id"),
22057
- sessionRef: url.searchParams.get("session_ref"),
22058
- res,
22059
- });
23216
+ const sessionTarget = resolveTranscriptTargetOrRespond({ url, claims, res });
22060
23217
  if (!sessionTarget) return;
22061
23218
  const session_id = sessionTarget.sessionId;
22062
- const targetSession = sessionManager.getSession(session_id);
22063
- if (!targetSession) {
22064
- return respond(res, 404, { error: "Session not found" });
22065
- }
22066
- const requestedAgentId = normalizeString(url.searchParams.get("agent_id"));
22067
- if (requestedAgentId && requestedAgentId !== targetSession.agentId) {
22068
- return respond(res, 403, {
22069
- error: "Matrix search target does not match session agent",
22070
- reason: "target_session_agent_mismatch",
22071
- });
22072
- }
22073
- const agent_id = requestedAgentId || targetSession.agentId;
22074
- let runtimeAuth = null;
22075
- if (isRuntimeCapabilityOnly(claims)) {
22076
- runtimeAuth = requireRuntimeCapability(claims, res, {
22077
- scope: "session:read_transcript",
22078
- sessionId: session_id,
22079
- agentId: agent_id,
22080
- });
22081
- if (!runtimeAuth) return;
22082
- } else if (!requireAgentCapability(claims, agent_id, "can_chat", res)) {
22083
- return;
22084
- }
23219
+ const agent_id = sessionTarget.agentId;
23220
+ const runtimeAuth = sessionTarget.runtimeAuth;
22085
23221
 
22086
23222
  const searchQuery = normalizeString(
22087
23223
  url.searchParams.get("q") || url.searchParams.get("query")
@@ -22121,7 +23257,7 @@ const server = createServer(async (req, res) => {
22121
23257
  }
22122
23258
  throw err;
22123
23259
  }
22124
- if (binding.host_agent_id !== agent_id) {
23260
+ if (binding.host_agent_id !== agent_id || binding.host_session_id !== session_id) {
22125
23261
  return respond(res, 403, {
22126
23262
  error: "Matrix search binding does not match agent",
22127
23263
  reason: "matrix_search_binding_agent_mismatch",
@@ -22150,14 +23286,15 @@ const server = createServer(async (req, res) => {
22150
23286
 
22151
23287
  let matrixSearch;
22152
23288
  try {
22153
- matrixSearch = searchRouteCMatrixRoomEvents({
23289
+ matrixSearch = readTranscriptTarget(sessionTarget, binding, () => searchRouteCMatrixRoomEvents({
22154
23290
  binding,
22155
23291
  searchTerm: searchQuery,
22156
23292
  limit,
22157
23293
  nextBatchToken: backendNextBatch,
22158
23294
  senderFilter,
22159
- });
23295
+ }));
22160
23296
  } catch (err) {
23297
+ if (respondTranscriptSourceError(res, err)) return;
22161
23298
  if (Number.isInteger(err?.status) && err?.body) {
22162
23299
  return respond(res, err.status, err.body);
22163
23300
  }
@@ -22235,41 +23372,17 @@ const server = createServer(async (req, res) => {
22235
23372
  pagination_contract: "opaque_next_batch_v1",
22236
23373
  raw_matrix_access_token_required: false,
22237
23374
  ...sessionRefResolutionResponseFields(sessionTarget.resolution),
23375
+ ...sessionTranscriptTargetFields(sessionTarget),
22238
23376
  });
22239
23377
  }
22240
23378
 
22241
23379
  // ── GET /session/messages-around?session_id=…&event_id=… ──
22242
23380
  if (req.method === "GET" && path === "/session/messages-around") {
22243
- const sessionTarget = resolveLiveSessionReferenceOrRespond({
22244
- sessionId: url.searchParams.get("session_id"),
22245
- sessionRef: url.searchParams.get("session_ref"),
22246
- res,
22247
- });
23381
+ const sessionTarget = resolveTranscriptTargetOrRespond({ url, claims, res });
22248
23382
  if (!sessionTarget) return;
22249
23383
  const session_id = sessionTarget.sessionId;
22250
- const targetSession = sessionManager.getSession(session_id);
22251
- if (!targetSession) {
22252
- return respond(res, 404, { error: "Session not found" });
22253
- }
22254
- const requestedAgentId = normalizeString(url.searchParams.get("agent_id"));
22255
- if (requestedAgentId && requestedAgentId !== targetSession.agentId) {
22256
- return respond(res, 403, {
22257
- error: "Matrix exact context target does not match session agent",
22258
- reason: "target_session_agent_mismatch",
22259
- });
22260
- }
22261
- const agent_id = requestedAgentId || targetSession.agentId;
22262
- let runtimeAuth = null;
22263
- if (isRuntimeCapabilityOnly(claims)) {
22264
- runtimeAuth = requireRuntimeCapability(claims, res, {
22265
- scope: "session:read_transcript",
22266
- sessionId: session_id,
22267
- agentId: agent_id,
22268
- });
22269
- if (!runtimeAuth) return;
22270
- } else if (!requireAgentCapability(claims, agent_id, "can_chat", res)) {
22271
- return;
22272
- }
23384
+ const agent_id = sessionTarget.agentId;
23385
+ const runtimeAuth = sessionTarget.runtimeAuth;
22273
23386
 
22274
23387
  const eventId = normalizeString(url.searchParams.get("event_id"));
22275
23388
  if (!eventId) {
@@ -22331,7 +23444,7 @@ const server = createServer(async (req, res) => {
22331
23444
  }
22332
23445
  throw err;
22333
23446
  }
22334
- if (binding.host_agent_id !== agent_id) {
23447
+ if (binding.host_agent_id !== agent_id || binding.host_session_id !== session_id) {
22335
23448
  return respond(res, 403, {
22336
23449
  error: "Matrix exact context binding does not match agent",
22337
23450
  reason: "matrix_context_binding_agent_mismatch",
@@ -22342,13 +23455,14 @@ const server = createServer(async (req, res) => {
22342
23455
 
22343
23456
  let exactContext;
22344
23457
  try {
22345
- exactContext = readMatrixTranscriptContext({
23458
+ exactContext = readTranscriptTarget(sessionTarget, binding, () => readMatrixTranscriptContext({
22346
23459
  binding,
22347
23460
  eventId,
22348
23461
  before,
22349
23462
  after,
22350
- });
23463
+ }));
22351
23464
  } catch (err) {
23465
+ if (respondTranscriptSourceError(res, err)) return;
22352
23466
  const projectionInvalid =
22353
23467
  err?.code === "MATRIX_CONTEXT_PROJECTION_INVALID";
22354
23468
  return respond(res, projectionInvalid ? 502 : 503, {
@@ -22437,62 +23551,20 @@ const server = createServer(async (req, res) => {
22437
23551
  exact_context_read: exactContext?.context || null,
22438
23552
  raw_matrix_access_token_required: false,
22439
23553
  ...sessionRefResolutionResponseFields(sessionTarget.resolution),
23554
+ ...sessionTranscriptTargetFields(sessionTarget),
22440
23555
  });
22441
23556
  }
22442
23557
 
22443
23558
  // ── GET /session/messages?session_id=…&agent_id=… ──────
22444
23559
  if (req.method === "GET" && path === "/session/messages") {
22445
- const sessionTarget = resolveLiveSessionReferenceOrRespond({
22446
- sessionId: url.searchParams.get("session_id"),
22447
- sessionRef: url.searchParams.get("session_ref"),
22448
- res,
22449
- });
23560
+ const sessionTarget = resolveTranscriptTargetOrRespond({ url, claims, res });
22450
23561
  if (!sessionTarget) return;
22451
23562
  const session_id = sessionTarget.sessionId;
23563
+ const agent_id = sessionTarget.agentId;
22452
23564
  const recentTailPresent = url.searchParams.has("recent_tail");
22453
23565
  const rawRecentTail = normalizeString(url.searchParams.get("recent_tail"));
22454
- if (recentTailPresent && rawRecentTail !== "true") {
22455
- return respond(res, 400, {
22456
- error: "recent_tail query param must be true when present",
22457
- });
22458
- }
23566
+ if (recentTailPresent && rawRecentTail !== "true") return respond(res, 400, { error: "recent_tail query param must be true when present" });
22459
23567
  const recentTailRequested = rawRecentTail === "true";
22460
- let targetSession = null;
22461
- if (recentTailRequested) {
22462
- targetSession = sessionManager.getSession(session_id);
22463
- if (!targetSession) {
22464
- return respond(res, 404, { error: "Session not found" });
22465
- }
22466
- }
22467
- const requestedAgentId = normalizeString(
22468
- url.searchParams.get("agent_id")
22469
- );
22470
- if (
22471
- recentTailRequested &&
22472
- requestedAgentId &&
22473
- requestedAgentId !== targetSession.agentId
22474
- ) {
22475
- return respond(res, 403, {
22476
- error: "Recent Matrix transcript target does not match session agent",
22477
- committed_transcript_truth: "matrix_room_timeline",
22478
- host_db_transcript_product_truth: false,
22479
- });
22480
- }
22481
- const agent_id =
22482
- requestedAgentId || (recentTailRequested ? targetSession.agentId : "");
22483
- if (!agent_id) {
22484
- return respond(res, 400, { error: "agent_id query param required" });
22485
- }
22486
- if (isRuntimeCapabilityOnly(claims)) {
22487
- const auth = requireRuntimeCapability(claims, res, {
22488
- scope: "session:read_transcript",
22489
- sessionId: session_id,
22490
- agentId: agent_id,
22491
- });
22492
- if (!auth) return;
22493
- } else if (!requireAgentCapability(claims, agent_id, "can_chat", res)) {
22494
- return;
22495
- }
22496
23568
 
22497
23569
  let limit;
22498
23570
  let afterSeq;
@@ -22510,29 +23582,35 @@ const server = createServer(async (req, res) => {
22510
23582
  let syncResult;
22511
23583
  try {
22512
23584
  const binding = requireRouteCMatrixRoomBinding(session_id);
22513
- if (binding.host_agent_id !== agent_id) {
23585
+ if (binding.host_agent_id !== agent_id || binding.host_session_id !== session_id) {
22514
23586
  return respond(res, 403, {
22515
23587
  error: "Matrix transcript read binding does not match agent",
22516
23588
  committed_transcript_truth: "matrix_room_timeline",
22517
23589
  host_db_transcript_product_truth: false,
22518
23590
  });
22519
23591
  }
22520
- syncResult = recentTailRequested
22521
- ? readMatrixTranscriptRecentTail({ binding, limit })
22522
- : readMatrixTranscriptMessagesAfter({
22523
- binding,
22524
- afterSeq,
22525
- limit,
22526
- });
23592
+ syncResult = readTranscriptTarget(sessionTarget, binding, () => {
23593
+ if (recentTailRequested) return readMatrixTranscriptRecentTail({ binding, limit });
23594
+ if (sessionTarget.target_state === "stopped") return historyTranscriptPager.read({
23595
+ binding, rawLimit: url.searchParams.get("limit"), cursor: url.searchParams.get("next_batch"),
23596
+ afterSeqPresent: url.searchParams.has("after_seq"),
23597
+ readAfter: (options) => readMatrixTranscriptMessagesAfter({ ...options, includeReadCheckpoint: true }),
23598
+ });
23599
+ if (url.searchParams.has("next_batch")) throw Object.assign(new Error("Live messages use after_seq, not history next_batch"), { status: 400, reason: "live_messages_cursor_incompatible" });
23600
+ return readMatrixTranscriptMessagesAfter({ binding, afterSeq, limit });
23601
+ });
22527
23602
  } catch (err) {
23603
+ if (respondTranscriptSourceError(res, err)) return;
23604
+ if (err.status && err.reason) return respond(res, err.status, { error: err.message, reason: err.reason });
22528
23605
  if (isRouteCMatrixBindingNotFoundError(err)) {
22529
23606
  return respond(res, 404, {
22530
23607
  error: "Matrix transcript read binding not available for session",
23608
+ reason: "matrix_transcript_binding_unavailable",
22531
23609
  committed_transcript_truth: "matrix_room_timeline",
22532
23610
  host_db_transcript_product_truth: false,
22533
23611
  });
22534
23612
  }
22535
- throw err;
23613
+ return respond(res, 503, { error: "Matrix transcript storage read failed", reason: "matrix_transcript_storage_unavailable", retryable: true });
22536
23614
  }
22537
23615
 
22538
23616
  return respond(res, 200, {
@@ -22550,7 +23628,7 @@ const server = createServer(async (req, res) => {
22550
23628
  one_bounded_matrix_tail_read: true,
22551
23629
  legacy_session_history_used: false,
22552
23630
  }
22553
- : { sync: syncResult.sync }),
23631
+ : { sync: syncResult.sync, ...(syncResult.page ? { page: syncResult.page, next_batch: syncResult.next_batch, pagination_contract: syncResult.pagination_contract } : {}) }),
22554
23632
  committed_transcript_truth: "matrix_room_timeline",
22555
23633
  host_db_transcript_product_truth: false,
22556
23634
  transcript_surface_classification: "product_matrix_path",
@@ -22559,9 +23637,168 @@ const server = createServer(async (req, res) => {
22559
23637
  product_legacy_transcript_caller_allowed: false,
22560
23638
  matrix_transcript_read: syncResult.matrix_transcript_read,
22561
23639
  ...sessionRefResolutionResponseFields(sessionTarget.resolution),
23640
+ ...sessionTranscriptTargetFields(sessionTarget),
22562
23641
  });
22563
23642
  }
22564
23643
 
23644
+ // ── POST /sessions/completed-message-read-visit ────────
23645
+ if (
23646
+ req.method === "POST" &&
23647
+ path === "/sessions/completed-message-read-visit"
23648
+ ) {
23649
+ const body = await readBody(req);
23650
+ const sessionId = normalizeString(body.session_id);
23651
+ const matrixRoomId = normalizeString(body.matrix_room_id);
23652
+ const documentVisitId = normalizeString(body.document_visit_id);
23653
+ const collectionId = normalizeString(body.collection_id);
23654
+ const collectionRevision = Number(body.collection_revision);
23655
+ if (!sessionId || !matrixRoomId || !documentVisitId ||
23656
+ !collectionId ||
23657
+ !Number.isSafeInteger(collectionRevision)) {
23658
+ return respond(res, 400, {
23659
+ error:
23660
+ "P250 visit requires Session, room, document and collection identity",
23661
+ code: "completed_session_unread_visit_identity_required",
23662
+ });
23663
+ }
23664
+ const session = requireSessionCapability(
23665
+ claims,
23666
+ sessionId,
23667
+ "can_chat",
23668
+ res
23669
+ );
23670
+ if (!session) return;
23671
+ const binding = getRouteCMatrixRoomBinding(sessionId);
23672
+ const collection = sessionCollectionService.snapshot({ view: "running" });
23673
+ if (
23674
+ !binding ||
23675
+ binding.matrix_room_id !== matrixRoomId ||
23676
+ binding.host_agent_id !== session.agentId ||
23677
+ collection.collection_id !== collectionId ||
23678
+ collection.revision !== collectionRevision ||
23679
+ !collection.rows.some((row) => row.session_id === sessionId)
23680
+ ) {
23681
+ return respond(res, 409, {
23682
+ error: "P250 visit binding or collection generation is stale",
23683
+ code: "completed_session_unread_visit_generation_mismatch",
23684
+ });
23685
+ }
23686
+ try {
23687
+ const visit = beginCompletedSessionChatVisit(
23688
+ openRouteCMatrixSQLiteStore({
23689
+ jsonStoragePath: getRouteCMatrixStoragePath(),
23690
+ }),
23691
+ {
23692
+ hostUserId: HOST_OWNER_READ_CURSOR_USER_ID,
23693
+ hostSessionId: sessionId,
23694
+ matrixRoomId,
23695
+ documentVisitId,
23696
+ hostGeneration: collection.collection_id,
23697
+ collectionRevision: collection.revision,
23698
+ bindingGeneration: binding.updated_at,
23699
+ authScope: normalizeString(claims.user_id) || "dashboard-admin",
23700
+ }
23701
+ );
23702
+ return respond(res, 200, {
23703
+ ...visit,
23704
+ collection_id: collection.collection_id,
23705
+ collection_revision: collection.revision,
23706
+ host_validated_binding: true,
23707
+ });
23708
+ } catch (err) {
23709
+ return respond(res, 409, {
23710
+ error: err instanceof Error ? err.message : String(err),
23711
+ code:
23712
+ normalizeString(err?.code) ||
23713
+ "completed_session_unread_visit_rejected",
23714
+ });
23715
+ }
23716
+ }
23717
+
23718
+ // ── POST /sessions/completed-message-mark-unread ──────
23719
+ if (
23720
+ req.method === "POST" &&
23721
+ path === "/sessions/completed-message-mark-unread"
23722
+ ) {
23723
+ const body = await readBody(req);
23724
+ const sessionId = normalizeString(body.session_id);
23725
+ const matrixRoomId = normalizeString(body.matrix_room_id);
23726
+ const collectionId = normalizeString(body.collection_id);
23727
+ const collectionRevision = Number(body.collection_revision);
23728
+ if (
23729
+ !sessionId ||
23730
+ !matrixRoomId ||
23731
+ !collectionId ||
23732
+ !Number.isSafeInteger(collectionRevision)
23733
+ ) {
23734
+ return respond(res, 400, {
23735
+ error:
23736
+ "Mark as unread requires Session, room, and collection identity",
23737
+ code: "completed_session_mark_unread_identity_required",
23738
+ });
23739
+ }
23740
+ const session = requireSessionCapability(
23741
+ claims,
23742
+ sessionId,
23743
+ "can_chat",
23744
+ res
23745
+ );
23746
+ if (!session) return;
23747
+ const binding = getRouteCMatrixRoomBinding(sessionId);
23748
+ const collection = sessionCollectionService.snapshot({ view: "running" });
23749
+ if (
23750
+ !binding ||
23751
+ binding.matrix_room_id !== matrixRoomId ||
23752
+ binding.host_agent_id !== session.agentId ||
23753
+ collection.collection_id !== collectionId ||
23754
+ collection.revision !== collectionRevision ||
23755
+ !collection.rows.some((row) => row.session_id === sessionId)
23756
+ ) {
23757
+ return respond(res, 409, {
23758
+ error: "Mark as unread binding or live Session collection is stale",
23759
+ code: "completed_session_mark_unread_generation_mismatch",
23760
+ });
23761
+ }
23762
+ try {
23763
+ const liveBindings = sessionListProjectionService.liveBindings(
23764
+ sessionManager.list()
23765
+ );
23766
+ const result = markLatestCompletedSessionUnread(
23767
+ openRouteCMatrixSQLiteStore({
23768
+ jsonStoragePath: getRouteCMatrixStoragePath(),
23769
+ }),
23770
+ {
23771
+ hostUserId: HOST_OWNER_READ_CURSOR_USER_ID,
23772
+ hostSessionId: sessionId,
23773
+ matrixRoomId,
23774
+ bindings: liveBindings,
23775
+ unreadScope: "live",
23776
+ includeUnreadProjection: true,
23777
+ }
23778
+ );
23779
+ return respond(res, 200, {
23780
+ ...result,
23781
+ collection_id: collection.collection_id,
23782
+ collection_revision: collection.revision,
23783
+ host_validated_binding: true,
23784
+ host_validated_latest_successful_final: true,
23785
+ });
23786
+ } catch (err) {
23787
+ const code =
23788
+ normalizeString(err?.code) ||
23789
+ "completed_session_mark_unread_rejected";
23790
+ const status =
23791
+ code === "completed_session_unread_binding_mismatch" ||
23792
+ code === "completed_session_unread_room_not_found"
23793
+ ? 403
23794
+ : 409;
23795
+ return respond(res, status, {
23796
+ error: err instanceof Error ? err.message : String(err),
23797
+ code,
23798
+ });
23799
+ }
23800
+ }
23801
+
22565
23802
  // ── POST /sessions/completed-message-read-ack ──────────
22566
23803
  if (
22567
23804
  req.method === "POST" &&
@@ -22585,10 +23822,13 @@ const server = createServer(async (req, res) => {
22585
23822
  );
22586
23823
  if (!session) return;
22587
23824
  const binding = getRouteCMatrixRoomBinding(sessionId);
23825
+ const collection = sessionCollectionService.snapshot({ view: "running" });
22588
23826
  if (
22589
23827
  !binding ||
22590
23828
  binding.matrix_room_id !== matrixRoomId ||
22591
- binding.host_agent_id !== session.agentId
23829
+ binding.host_agent_id !== session.agentId ||
23830
+ collection.collection_id !== normalizeString(body.collection_id) ||
23831
+ collection.revision !== Number(body.collection_revision)
22592
23832
  ) {
22593
23833
  return respond(res, 403, {
22594
23834
  error: "Completed-message acknowledgement binding does not match the current Chat",
@@ -22597,18 +23837,38 @@ const server = createServer(async (req, res) => {
22597
23837
  }
22598
23838
  if (
22599
23839
  body.acknowledgement_contract !==
22600
- "routec.p217_visible_completed_message_ack.v1" ||
23840
+ "routec.p250_latest_timeline_read_ack.v1" ||
22601
23841
  body.materially_rendered !== true ||
22602
- body.viewport_visible !== true ||
23842
+ body.latest_timeline_loaded !== true ||
22603
23843
  body.document_visible !== true ||
22604
- body.document_focused !== true
23844
+ body.document_focused !== true ||
23845
+ !normalizeString(body.visit_proof) ||
23846
+ !normalizeString(body.host_generation) ||
23847
+ !normalizeString(body.binding_generation)
22605
23848
  ) {
22606
23849
  return respond(res, 400, {
22607
- error: "Completed-message acknowledgement requires positive rendered and viewport visibility proof",
22608
- code: "completed_session_unread_visibility_proof_required",
23850
+ error:
23851
+ "Completed-message acknowledgement requires a qualified P250 visit and latest timeline proof",
23852
+ code: "completed_session_unread_readiness_proof_required",
23853
+ });
23854
+ }
23855
+ const unreadScope = normalizeString(body.unread_scope);
23856
+ const includeUnreadProjection = body.include_unread_projection === true;
23857
+ if (
23858
+ (unreadScope && unreadScope !== "live") ||
23859
+ (body.include_unread_projection !== undefined &&
23860
+ typeof body.include_unread_projection !== "boolean") ||
23861
+ (includeUnreadProjection && unreadScope !== "live")
23862
+ ) {
23863
+ return respond(res, 400, {
23864
+ error: "Completed-message acknowledgement unread projection requires unread_scope live",
23865
+ code: "invalid_session_unread_scope",
22609
23866
  });
22610
23867
  }
22611
23868
  try {
23869
+ const liveBindings = unreadScope === "live"
23870
+ ? sessionListProjectionService.liveBindings(sessionManager.list())
23871
+ : null;
22612
23872
  const acknowledgement = acknowledgeCompletedSessionRead(
22613
23873
  openRouteCMatrixSQLiteStore({
22614
23874
  jsonStoragePath: getRouteCMatrixStoragePath(),
@@ -22618,12 +23878,21 @@ const server = createServer(async (req, res) => {
22618
23878
  hostSessionId: sessionId,
22619
23879
  matrixRoomId,
22620
23880
  eventId,
23881
+ bindings: liveBindings,
23882
+ unreadScope: unreadScope || null,
23883
+ includeUnreadProjection,
23884
+ visitProof: body.visit_proof,
23885
+ hostGeneration: body.host_generation,
23886
+ collectionRevision: Number(body.collection_revision),
23887
+ bindingGeneration: body.binding_generation,
23888
+ authScope: normalizeString(claims.user_id) || "dashboard-admin",
22621
23889
  }
22622
23890
  );
22623
23891
  return respond(res, 200, {
22624
23892
  ...acknowledgement,
22625
- acknowledgement_contract:
22626
- "routec.p217_visible_completed_message_ack.v1",
23893
+ collection_id: collection.collection_id,
23894
+ collection_revision: collection.revision,
23895
+ acknowledgement_contract: "routec.p250_latest_timeline_read_ack.v1",
22627
23896
  host_validated_binding: true,
22628
23897
  host_validated_latest_successful_final: true,
22629
23898
  });
@@ -22641,15 +23910,41 @@ const server = createServer(async (req, res) => {
22641
23910
  }
22642
23911
  }
22643
23912
 
23913
+ // ── GET /sessions/collection ───────────────────────────
23914
+ if (req.method === "GET" && path === "/sessions/collection") {
23915
+ if (!requireDashboardMode(claims, res)) return;
23916
+ const view = url.searchParams.get("view") === "running" ? "running" : "all";
23917
+ commitSessionCollectionChange(["read_reconcile"]);
23918
+ const sinceText = url.searchParams.get("since_revision");
23919
+ if (sinceText === null) {
23920
+ return respond(res, 200, sessionCollectionService.snapshot({ view }));
23921
+ }
23922
+ const sinceRevision = Number(sinceText);
23923
+ if (!Number.isSafeInteger(sinceRevision) || sinceRevision < 0) {
23924
+ return respond(res, 400, {
23925
+ error: "since_revision must be a non-negative integer",
23926
+ code: "invalid_session_collection_revision",
23927
+ });
23928
+ }
23929
+ return respond(res, 200, sessionCollectionService.delta({
23930
+ sinceRevision,
23931
+ collectionId: normalizeString(url.searchParams.get("collection_id")),
23932
+ view,
23933
+ }));
23934
+ }
23935
+
22644
23936
  // ── GET /sessions/previews ─────────────────────────────
22645
23937
  if (req.method === "GET" && path === "/sessions/previews") {
22646
23938
  if (!requireDashboardMode(claims, res)) return;
22647
23939
  const liveSessions = sessionManager.list();
22648
- const liveSessionIds = new Set(
22649
- liveSessions
22650
- .map((session) => session.id || session.sessionId)
22651
- .filter(Boolean)
22652
- );
23940
+ // P236 U1 / docs/routec/dev/session_list_progressive_loading.md:
23941
+ // Dashboard uses /sessions?include_previews=0 plus History/catalog.
23942
+ // Chat needs only live names first, then one P032 decoration batch;
23943
+ // never reconnect this metadata response to historical decoration reads.
23944
+ if (url.searchParams.get("include_session_switcher") === "1" &&
23945
+ url.searchParams.get("view") === "metadata") {
23946
+ return respond(res, 200, sessionListProjectionService.metadata(liveSessions));
23947
+ }
22653
23948
  const entriesBySessionId = new Map();
22654
23949
  for (const session of liveSessions) {
22655
23950
  const sessionId = session.id || session.sessionId;
@@ -22662,8 +23957,6 @@ const server = createServer(async (req, res) => {
22662
23957
  if (
22663
23958
  record.session_id &&
22664
23959
  record.agent_id &&
22665
- record.agent_folder &&
22666
- existsSync(record.agent_folder) &&
22667
23960
  !entriesBySessionId.has(record.session_id)
22668
23961
  ) {
22669
23962
  entriesBySessionId.set(record.session_id, {
@@ -22673,71 +23966,20 @@ const server = createServer(async (req, res) => {
22673
23966
  }
22674
23967
  }
22675
23968
 
22676
- const bindingBySessionId = new Map();
22677
- const bindings = [];
22678
- const seenRoomIds = new Set();
22679
- for (const entry of entriesBySessionId.values()) {
22680
- const binding = getRouteCMatrixRoomBinding(entry.sessionId);
22681
- if (!binding || binding.host_agent_id !== entry.agentId) continue;
22682
- bindingBySessionId.set(entry.sessionId, binding);
22683
- if (!seenRoomIds.has(binding.matrix_room_id)) {
22684
- seenRoomIds.add(binding.matrix_room_id);
22685
- bindings.push(binding);
22686
- }
23969
+ const unreadScope = normalizeString(url.searchParams.get("unread_scope"));
23970
+ if (unreadScope && unreadScope !== "live") {
23971
+ return respond(res, 400, {
23972
+ error: "unread_scope must be live when provided",
23973
+ code: "invalid_session_unread_scope",
23974
+ });
22687
23975
  }
22688
- const resultByRoomId =
22689
- sessionListPreviewService.readForBindings(bindings);
22690
- const unreadProjection = readCompletedSessionUnreadProjection(
22691
- openRouteCMatrixSQLiteStore({
22692
- jsonStoragePath: getRouteCMatrixStoragePath(),
22693
- }),
22694
- {
22695
- bindings,
22696
- hostUserId: HOST_OWNER_READ_CURSOR_USER_ID,
22697
- }
22698
- );
22699
- const previews = [];
22700
- for (const entry of entriesBySessionId.values()) {
22701
- const binding = bindingBySessionId.get(entry.sessionId) || null;
22702
- if (!binding) {
22703
- previews.push({
22704
- session_id: entry.sessionId,
22705
- status: "no_matrix_binding",
22706
- last_message_preview: null,
22707
- latest_committed_seq: 0,
22708
- latest_completed_event_id: null,
22709
- latest_completed_stream_seq: 0,
22710
- read_through_stream_seq: 0,
22711
- has_unread_completed_message: false,
22712
- cache_status: "not_applicable",
22713
- error_code: null,
22714
- });
22715
- continue;
22716
- }
22717
- const result = resultByRoomId.get(binding.matrix_room_id);
22718
- const unread =
22719
- unreadProjection.resultBySessionId.get(entry.sessionId) || {};
22720
- const isLiveSession = liveSessionIds.has(entry.sessionId);
22721
- previews.push({
22722
- session_id: entry.sessionId,
22723
- status: result.status,
22724
- last_message_preview: result.last_message_preview,
22725
- latest_committed_seq: result.latest_committed_seq,
22726
- latest_completed_event_id:
22727
- unread.latest_completed_event_id || null,
22728
- latest_completed_stream_seq:
22729
- Number(unread.latest_completed_stream_seq) || 0,
22730
- read_through_stream_seq:
22731
- Number(unread.read_through_stream_seq) || 0,
22732
- has_unread_completed_message:
22733
- isLiveSession && unread.has_unread_completed_message === true,
22734
- cache_status: result.cache_status,
22735
- error_code: result.error_code,
22736
- });
22737
- }
22738
- const liveUnreadSessionCount = previews.filter(
22739
- (preview) => preview.has_unread_completed_message === true
22740
- ).length;
23976
+ const { previews, sessionRows, uniqueRoomCount,
23977
+ unreadSchemaVersion, liveUnreadSessionCount } =
23978
+ sessionListProjectionService.decorate(
23979
+ [...entriesBySessionId.values()],
23980
+ liveSessions,
23981
+ { unreadScope: unreadScope || "all" }
23982
+ );
22741
23983
  const includeSessionSwitcher =
22742
23984
  url.searchParams.get("include_session_switcher") === "1";
22743
23985
  if (!includeSessionSwitcher) {
@@ -22745,10 +23987,10 @@ const server = createServer(async (req, res) => {
22745
23987
  status: "session_list_previews",
22746
23988
  previews,
22747
23989
  session_count: entriesBySessionId.size,
22748
- unique_room_count: bindings.length,
23990
+ unique_room_count: uniqueRoomCount,
22749
23991
  unread_session_count: liveUnreadSessionCount,
22750
23992
  completed_session_unread_schema_version:
22751
- unreadProjection.schema_version,
23993
+ unreadSchemaVersion,
22752
23994
  dedicated_preview_query: true,
22753
23995
  full_transcript_pagination_used: false,
22754
23996
  room_aggregate_query_used: false,
@@ -22757,23 +23999,15 @@ const server = createServer(async (req, res) => {
22757
23999
  committed_transcript_truth: "matrix_room_timeline",
22758
24000
  });
22759
24001
  }
22760
- const previewBySessionId = new Map(
22761
- previews.map((preview) => [preview.session_id, preview])
22762
- );
22763
- const sessionRows = buildSessionSwitcherRows({
22764
- liveSessions,
22765
- previewBySessionId,
22766
- bindingBySessionId,
22767
- });
22768
24002
  return respond(res, 200, {
22769
24003
  status: "session_list_previews",
22770
24004
  previews,
22771
24005
  session_rows: sessionRows,
22772
24006
  session_count: sessionRows.length,
22773
- unique_room_count: bindings.length,
24007
+ unique_room_count: uniqueRoomCount,
22774
24008
  unread_session_count: liveUnreadSessionCount,
22775
24009
  completed_session_unread_schema_version:
22776
- unreadProjection.schema_version,
24010
+ unreadSchemaVersion,
22777
24011
  dedicated_preview_query: true,
22778
24012
  full_transcript_pagination_used: false,
22779
24013
  room_aggregate_query_used: false,
@@ -22785,28 +24019,65 @@ const server = createServer(async (req, res) => {
22785
24019
  session_switcher_projection_batched: true,
22786
24020
  session_switcher_per_row_http_requests: 0,
22787
24021
  session_switcher_matrix_query_count: 0,
24022
+ ...(unreadScope === "live"
24023
+ ? {
24024
+ unread_scope: "live",
24025
+ unread_projection_contract:
24026
+ "routec.p236_live_completed_session_unread_projection.v1",
24027
+ }
24028
+ : {}),
22788
24029
  });
22789
24030
  }
22790
24031
 
22791
24032
  // ── GET /sessions/history ──────────────────────────────
22792
24033
  if (req.method === "GET" && path === "/sessions/history") {
22793
24034
  const includePreviews = shouldIncludeSessionListPreviews(url);
22794
- const history = getSessionHistory()
22795
- .filter((s) => s.agent_folder && existsSync(s.agent_folder))
22796
- .map((s) => {
22797
- const row = { ...s };
22798
- if (includePreviews) {
22799
- row.last_message_preview = getLastMessagePreview(
22800
- s.agent_folder,
22801
- s.agent_id,
22802
- s.session_id
22803
- );
22804
- }
22805
- return row;
22806
- });
24035
+ const canonicalHistoryBySessionId = new Map(
24036
+ buildCanonicalSessionCollectionRows()
24037
+ .filter((row) => row.kind === "history")
24038
+ .map((row) => [row.session_id, row])
24039
+ );
24040
+ const eligible = getSessionHistory();
24041
+ const previews = includePreviews ? sessionListProjectionService.previewOnly(
24042
+ eligible.map((s) => ({ sessionId: s.session_id, agentId: s.agent_id }))
24043
+ ) : null;
24044
+ const history = eligible.map((s) => ({
24045
+ ...s,
24046
+ restore_failed:
24047
+ canonicalHistoryBySessionId.get(s.session_id)?.restore_failed === true,
24048
+ restore_failed_reason:
24049
+ canonicalHistoryBySessionId.get(s.session_id)?.restore_failed_reason || null,
24050
+ manual_start_supported:
24051
+ canonicalHistoryBySessionId.get(s.session_id)?.manual_start_supported === true,
24052
+ manual_start_setup_reason:
24053
+ canonicalHistoryBySessionId.get(s.session_id)?.manual_start_setup_reason || null,
24054
+ resume_supported:
24055
+ canonicalHistoryBySessionId.get(s.session_id)?.resume_supported === true,
24056
+ resume_unavailable_reason:
24057
+ canonicalHistoryBySessionId.get(s.session_id)?.resume_unavailable_reason || null,
24058
+ ...(includePreviews ? { last_message_preview: previews.get(s.session_id) ?? null } : {}),
24059
+ }));
22807
24060
  return respond(res, 200, { sessions: history });
22808
24061
  }
22809
24062
 
24063
+ // ── GET /sessions/tags/recent ──────────────────────────
24064
+ if (req.method === "GET" && path === "/sessions/tags/recent") {
24065
+ if (!requireDashboardMode(claims, res)) return;
24066
+ try {
24067
+ return respond(res, 200, {
24068
+ status: "recent_session_tags",
24069
+ tags: new RecentSessionTagsStore(getConfigDir()).read(),
24070
+ bounded: true,
24071
+ host_local: true,
24072
+ });
24073
+ } catch (err) {
24074
+ return respond(res, 500, {
24075
+ error: err.message || String(err),
24076
+ code: "recent_session_tags_unavailable",
24077
+ });
24078
+ }
24079
+ }
24080
+
22810
24081
  // ── GET /sessions/default-name?agent_id=…&mode=branch&source_session_id=… ──
22811
24082
  // Returns the counter-based `${parent}-branch-N` default a subsequent
22812
24083
  // `/sessions/resume` branch would resolve, WITHOUT bumping the counter.
@@ -22944,9 +24215,13 @@ const server = createServer(async (req, res) => {
22944
24215
 
22945
24216
  const transcripts = [];
22946
24217
  const resumeDispositions = [];
22947
- for (const record of getSessionHistory()) {
22948
- if (record.agent_id !== agent_id || !record.session_id) continue;
22949
- const binding = getRouteCMatrixRoomBinding(record.session_id);
24218
+ const records = getSessionHistory().filter(
24219
+ (record) => record.agent_id === agent_id && record.session_id
24220
+ );
24221
+ // R3 batches only the binding read; P210 timeline replay remains truth.
24222
+ const bindings = getRouteCMatrixRoomBindings(records.map((r) => r.session_id));
24223
+ for (const record of records) {
24224
+ const binding = bindings.get(record.session_id);
22950
24225
  let matrixTranscriptAvailable = false;
22951
24226
  let transcript = null;
22952
24227
  try {
@@ -22994,6 +24269,7 @@ const server = createServer(async (req, res) => {
22994
24269
 
22995
24270
  // ── POST /sessions/resume ──────────────────────────────
22996
24271
  if (req.method === "POST" && path === "/sessions/resume") {
24272
+ try { assertSessionRosterMutationAvailable(); } catch (error) { return respond(res, 409, { error: error.message, code: error.code }); }
22997
24273
  const body = await readBody(req);
22998
24274
  if (rejectRetiredCapabilitySettingInput(res, body)) return;
22999
24275
  const { source_session_id, agent_id, agent_folder, session_name } = body;
@@ -23028,6 +24304,14 @@ const server = createServer(async (req, res) => {
23028
24304
  error: "Resume source agent does not match requested agent",
23029
24305
  });
23030
24306
  }
24307
+ let childTags;
24308
+ try {
24309
+ childTags = Object.prototype.hasOwnProperty.call(body, "tags")
24310
+ ? normalizeSessionTags(body.tags)
24311
+ : normalizeSessionTags([sourceRecord.session_name]);
24312
+ } catch (err) {
24313
+ return respond(res, 400, { error: err.message, code: err.code });
24314
+ }
23031
24315
 
23032
24316
  if (!sourceRecord.agent_folder) {
23033
24317
  return respond(res, 400, {
@@ -23202,11 +24486,14 @@ const server = createServer(async (req, res) => {
23202
24486
  });
23203
24487
  let session;
23204
24488
  try {
24489
+ assertSessionRosterMutationAvailable();
23205
24490
  session = sessionManager.start({
23206
24491
  agentId: agent_id,
23207
24492
  cwd: resolvedFolder,
23208
24493
  sessionId: childSessionId,
23209
24494
  sessionName: resolvedSessionName,
24495
+ tags: childTags,
24496
+ deferTagsPersistence: true,
23210
24497
  provider: resolved.runtime.provider,
23211
24498
  parentSessionId: sourceSessionId,
23212
24499
  resumeSessionId: forkSourceProviderMetadata.providerResumeId,
@@ -23341,11 +24628,13 @@ const server = createServer(async (req, res) => {
23341
24628
  recordTelemetryCounter("session_branch_resumed_count");
23342
24629
  recordTelemetryFeature("sessions");
23343
24630
  recordTelemetryFeature("chat");
23344
- return respond(res, 200, {
24631
+ const persistedTags = canonicalSessionTags(session);
24632
+ const response = {
23345
24633
  success: true,
23346
24634
  session_id: session.id,
23347
24635
  session_name: session.sessionName,
23348
24636
  parent_session_id: sourceSessionId,
24637
+ tags: persistedTags,
23349
24638
  agent_id,
23350
24639
  provider: session.provider || "claude",
23351
24640
  permission_mode: session.permissionMode ?? null,
@@ -23412,7 +24701,10 @@ const server = createServer(async (req, res) => {
23412
24701
  bookkeeping_warning: bookkeepingWarnings.length
23413
24702
  ? bookkeepingWarnings.join("; ")
23414
24703
  : null,
23415
- });
24704
+ };
24705
+ commitSuccessfulSessionTags(session, { changeKinds: ["branch_resume", "create"] });
24706
+ refreshSessionBookkeepingWarning(response, session);
24707
+ return respond(res, 200, response);
23416
24708
  }
23417
24709
 
23418
24710
  // ── POST /session/resume ───────────────────────────────
@@ -23420,6 +24712,7 @@ const server = createServer(async (req, res) => {
23420
24712
  // therefore the Route C Matrix room binding key. Branch/fork creation stays
23421
24713
  // on /sessions/resume.
23422
24714
  if (req.method === "POST" && path === "/session/resume") {
24715
+ try { assertSessionRosterMutationAvailable(); } catch (error) { return respond(res, 409, { error: error.message, code: error.code }); }
23423
24716
  const body = await readBody(req);
23424
24717
  if (rejectRetiredCapabilitySettingInput(res, body)) return;
23425
24718
  const {
@@ -23482,6 +24775,14 @@ const server = createServer(async (req, res) => {
23482
24775
  error: "Resume source agent does not match requested agent",
23483
24776
  });
23484
24777
  }
24778
+ let resumedTags;
24779
+ try {
24780
+ resumedTags = Object.prototype.hasOwnProperty.call(body, "tags")
24781
+ ? normalizeSessionTags(body.tags)
24782
+ : canonicalSessionTags(sourceRecord);
24783
+ } catch (err) {
24784
+ return respond(res, 400, { error: err.message, code: err.code });
24785
+ }
23485
24786
 
23486
24787
  if (!sourceRecord.agent_folder) {
23487
24788
  return respond(res, 400, {
@@ -23492,13 +24793,13 @@ const server = createServer(async (req, res) => {
23492
24793
  let sourceCanonicalFolder;
23493
24794
  let requestedCanonicalFolder;
23494
24795
  try {
23495
- sourceCanonicalFolder = realpathSync(sourceRecord.agent_folder);
24796
+ sourceCanonicalFolder = requireAgentFolder(sourceRecord.agent_folder);
23496
24797
  requestedCanonicalFolder = agent_folder
23497
24798
  ? realpathSync(agent_folder)
23498
24799
  : sourceCanonicalFolder;
23499
24800
  } catch (err) {
23500
24801
  return respond(res, 400, {
23501
- error: `Resume folder validation failed: ${err.message}`,
24802
+ ...agentFolderErrorResponse(err),
23502
24803
  });
23503
24804
  }
23504
24805
  if (sourceCanonicalFolder !== requestedCanonicalFolder) {
@@ -23697,6 +24998,12 @@ const server = createServer(async (req, res) => {
23697
24998
  });
23698
24999
  }
23699
25000
 
25001
+ // Recheck after all asynchronous preparation, before provider construction.
25002
+ try {
25003
+ requireAgentFolder(resolvedFolder);
25004
+ } catch (err) {
25005
+ return respond(res, 400, agentFolderErrorResponse(err));
25006
+ }
23700
25007
  const sessionRuntimeCapabilities =
23701
25008
  resolveRuntimeCapabilitiesForSessionStart(resolved.config, body);
23702
25009
  const runtimeCapabilityGrant = buildLiveSessionRuntimeCapabilityGrant({
@@ -23706,11 +25013,14 @@ const server = createServer(async (req, res) => {
23706
25013
  });
23707
25014
  let session;
23708
25015
  try {
25016
+ assertSessionRosterMutationAvailable();
23709
25017
  session = sessionManager.start({
23710
25018
  agentId: agent_id,
23711
25019
  cwd: resolvedFolder,
23712
25020
  sessionId: sourceSessionId,
23713
25021
  sessionName: resolvedSessionName,
25022
+ tags: resumedTags,
25023
+ deferTagsPersistence: true,
23714
25024
  provider: resolved.runtime.provider,
23715
25025
  parentSessionId: sourceRecord.parent_session_id || null,
23716
25026
  resumeSessionId: sourceProviderMetadata.providerResumeId,
@@ -23847,12 +25157,14 @@ const server = createServer(async (req, res) => {
23847
25157
  recordTelemetryCounter("session_resumed_count");
23848
25158
  recordTelemetryFeature("sessions");
23849
25159
  recordTelemetryFeature("chat");
23850
- return respond(res, 200, {
25160
+ const persistedTags = canonicalSessionTags(session);
25161
+ const response = {
23851
25162
  success: true,
23852
25163
  session_id: session.id,
23853
25164
  source_session_id: sourceSessionId,
23854
25165
  session_name: session.sessionName,
23855
25166
  parent_session_id: session.parentSessionId || null,
25167
+ tags: persistedTags,
23856
25168
  agent_id,
23857
25169
  provider: session.provider || "claude",
23858
25170
  permission_mode: session.permissionMode ?? null,
@@ -23919,13 +25231,26 @@ const server = createServer(async (req, res) => {
23919
25231
  bookkeeping_warning: bookkeepingWarnings.length
23920
25232
  ? bookkeepingWarnings.join("; ")
23921
25233
  : null,
25234
+ };
25235
+ commitSuccessfulSessionTags(session, {
25236
+ promote: Object.prototype.hasOwnProperty.call(body, "tags"),
25237
+ restoreReady: true,
25238
+ changeKinds: ["resume"],
23922
25239
  });
25240
+ refreshSessionBookkeepingWarning(response, session);
25241
+ return respond(res, 200, response);
23923
25242
  }
23924
25243
 
23925
25244
  // ── GET /sessions ──────────────────────────────────────
23926
25245
  if (req.method === "GET" && path === "/sessions") {
23927
25246
  const includePreviews = shouldIncludeSessionListPreviews(url);
23928
- const allSessions = sessionManager.list();
25247
+ const allSessions = sessionManager.list().filter((session) => {
25248
+ const sessionId = session.id || session.sessionId;
25249
+ return (
25250
+ sessionManager.getDesiredOpenRestoreOutcome(sessionId)?.state !==
25251
+ "failed"
25252
+ );
25253
+ });
23929
25254
  let accessible;
23930
25255
  if (isRuntimeCapabilityOnly(claims)) {
23931
25256
  const auth = requireRuntimeCapability(claims, res, {
@@ -23938,7 +25263,11 @@ const server = createServer(async (req, res) => {
23938
25263
  } else {
23939
25264
  accessible = allSessions.filter((s) => claims.agent_ids.includes(s.agentId));
23940
25265
  }
25266
+ const previews = includePreviews ? sessionListProjectionService.previewOnly(
25267
+ accessible.map((s) => ({ sessionId: s.id || s.sessionId, agentId: s.agentId }))
25268
+ ) : null;
23941
25269
  return respond(res, 200, {
25270
+ desired_open_restore: buildDesiredOpenRestoreStatusPayload(),
23942
25271
  sessions: accessible.map((session) => {
23943
25272
  const sessionId = session.id || session.sessionId;
23944
25273
  const liveSession = sessionId
@@ -23976,11 +25305,7 @@ const server = createServer(async (req, res) => {
23976
25305
  ),
23977
25306
  };
23978
25307
  if (includePreviews) {
23979
- row.lastMessagePreview = getLastMessagePreview(
23980
- session.cwd,
23981
- session.agentId,
23982
- sessionId
23983
- );
25308
+ row.lastMessagePreview = previews.get(sessionId) ?? null;
23984
25309
  }
23985
25310
  return row;
23986
25311
  }),
@@ -24027,6 +25352,7 @@ const server = createServer(async (req, res) => {
24027
25352
 
24028
25353
  const notificationWss = new WebSocketServer({ noServer: true });
24029
25354
  const mailNotificationWss = new WebSocketServer({ noServer: true });
25355
+ const sessionCollectionWss = new WebSocketServer({ noServer: true });
24030
25356
  const wss = new WebSocketServer({ noServer: true });
24031
25357
 
24032
25358
  server.on("upgrade", (req, socket, head) => {
@@ -24043,6 +25369,12 @@ server.on("upgrade", (req, socket, head) => {
24043
25369
  });
24044
25370
  return;
24045
25371
  }
25372
+ if (url.pathname === "/sessions/collection/stream") {
25373
+ sessionCollectionWss.handleUpgrade(req, socket, head, (ws) => {
25374
+ sessionCollectionWss.emit("connection", ws, req);
25375
+ });
25376
+ return;
25377
+ }
24046
25378
  if (url.pathname === "/session/stream") {
24047
25379
  wss.handleUpgrade(req, socket, head, (ws) => {
24048
25380
  wss.emit("connection", ws, req);
@@ -24134,6 +25466,27 @@ mailNotificationWss.on("connection", (ws, req) => {
24134
25466
  });
24135
25467
  });
24136
25468
 
25469
+ sessionCollectionWss.on("connection", (ws, req) => {
25470
+ const url = new URL(req.url, `http://localhost:${PORT}`);
25471
+ const claims = authenticateDashboardNotificationWebSocket(req, url);
25472
+ if (!claims?._dashboardAuth) {
25473
+ ws.send(JSON.stringify({ type: "error", error: "Dashboard authentication required" }));
25474
+ ws.close(4001, "Authentication failed");
25475
+ return;
25476
+ }
25477
+ commitSessionCollectionChange(["feed_handshake"]);
25478
+ sessionCollectionSubscribers.add(ws);
25479
+ const diagnostics = sessionCollectionService.diagnostics();
25480
+ ws.send(JSON.stringify({
25481
+ type: "sessions.collection.status",
25482
+ collection_id: diagnostics.collection_id,
25483
+ revision: diagnostics.revision,
25484
+ active: true,
25485
+ }));
25486
+ ws.on("close", () => sessionCollectionSubscribers.delete(ws));
25487
+ ws.on("error", () => sessionCollectionSubscribers.delete(ws));
25488
+ });
25489
+
24137
25490
  wss.on("connection", (ws, req) => {
24138
25491
  const url = new URL(req.url, `http://localhost:${PORT}`);
24139
25492
  const sessionId = url.searchParams.get("session_id");
@@ -24795,6 +26148,262 @@ async function restoreTelegramListenersAfterHostRestart({
24795
26148
  };
24796
26149
  }
24797
26150
 
26151
+ function waitForDesiredOpenSessionTerminal(
26152
+ sessionId,
26153
+ { timeoutMs = DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS } = {}
26154
+ ) {
26155
+ const session = sessionManager.getSession(sessionId);
26156
+ if (!session) {
26157
+ return Promise.resolve({ state: "failed", reason: "provider_runtime_exited" });
26158
+ }
26159
+ if (session._ready === true) return Promise.resolve({ state: "ready" });
26160
+ if (timeoutMs === 0) {
26161
+ return Promise.resolve({ state: "failed", reason: "restore_timeout" });
26162
+ }
26163
+ return new Promise((resolve) => {
26164
+ const timer = setTimeout(
26165
+ () => finish({ state: "failed", reason: "restore_timeout" }),
26166
+ timeoutMs
26167
+ );
26168
+ timer.unref?.();
26169
+ function finish(outcome) {
26170
+ clearTimeout(timer);
26171
+ session.off("event", onEvent);
26172
+ session.off("error", onError);
26173
+ session.off("exit", onExit);
26174
+ resolve(outcome);
26175
+ }
26176
+ function onEvent(event) {
26177
+ if (event?.type === "session.ready") finish({ state: "ready" });
26178
+ }
26179
+ function onError() {
26180
+ const outcome = sessionManager.getDesiredOpenRestoreOutcome(sessionId);
26181
+ finish({
26182
+ state: "failed",
26183
+ reason: outcome?.reason || "provider_runtime_restore_failed",
26184
+ });
26185
+ }
26186
+ function onExit() {
26187
+ finish({ state: "failed", reason: "provider_runtime_exited" });
26188
+ }
26189
+ session.on("event", onEvent);
26190
+ session.on("error", onError);
26191
+ session.on("exit", onExit);
26192
+ });
26193
+ }
26194
+
26195
+ async function executeDesiredOpenReconciliation({
26196
+ trigger,
26197
+ sessionIds = null,
26198
+ timeoutMs = DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS,
26199
+ }) {
26200
+ const reconciliationId = randomUUID();
26201
+ const startedAt = new Date().toISOString();
26202
+ const desired = sessionManager.getDesiredOpenRestoreStatus();
26203
+ lastDesiredOpenRestoreStatus = {
26204
+ status: "restoring",
26205
+ trigger,
26206
+ reconciliation_id: reconciliationId,
26207
+ running: true,
26208
+ started_at: startedAt,
26209
+ completed_at: null,
26210
+ };
26211
+ if (!desired.valid) {
26212
+ lastDesiredOpenRestoreStatus = {
26213
+ ...lastDesiredOpenRestoreStatus,
26214
+ status: desired.status,
26215
+ running: false,
26216
+ completed_at: new Date().toISOString(),
26217
+ };
26218
+ return buildDesiredOpenRestoreStatusPayload();
26219
+ }
26220
+
26221
+ const selectedIds = Array.isArray(sessionIds) ? new Set(sessionIds) : null;
26222
+ const queue = desired.sessions.filter(
26223
+ (entry) =>
26224
+ entry.state !== "failed" &&
26225
+ (!selectedIds || selectedIds.has(entry.session_id))
26226
+ );
26227
+ const aggregate = { restored: [], skipped: [], failed: [] };
26228
+ let nextIndex = 0;
26229
+ const worker = async () => {
26230
+ while (nextIndex < queue.length) {
26231
+ const record = queue[nextIndex];
26232
+ nextIndex += 1;
26233
+ const result = sessionManager.restoreDesiredOpenSessions({
26234
+ reconciliationId,
26235
+ sessionIds: [record.session_id],
26236
+ buildRuntimeCapabilityGrant: ({ sessionId, agentId, capabilities }) =>
26237
+ buildLiveSessionRuntimeCapabilityGrant({
26238
+ sessionId,
26239
+ agentId,
26240
+ capabilities,
26241
+ }),
26242
+ });
26243
+ aggregate.failed.push(...result.failed);
26244
+ for (const skippedEntry of result.skipped) {
26245
+ const liveSession = sessionManager.getSession(skippedEntry.session_id);
26246
+ if (
26247
+ skippedEntry.reason === "already_live" &&
26248
+ liveSession?._ready !== true
26249
+ ) {
26250
+ const terminal = await waitForDesiredOpenSessionTerminal(
26251
+ skippedEntry.session_id,
26252
+ { timeoutMs }
26253
+ );
26254
+ if (terminal.state === "failed") {
26255
+ const failure = {
26256
+ session_id: skippedEntry.session_id,
26257
+ agent_id: record.agent_id,
26258
+ provider: record.provider,
26259
+ reason: terminal.reason,
26260
+ };
26261
+ sessionManager.markDesiredOpenRestoreFailure(
26262
+ skippedEntry.session_id,
26263
+ { ...failure, reconciliationId }
26264
+ );
26265
+ aggregate.failed.push(failure);
26266
+ continue;
26267
+ }
26268
+ }
26269
+ aggregate.skipped.push(skippedEntry);
26270
+ }
26271
+ for (const restoredEntry of result.restored) {
26272
+ const terminal = await waitForDesiredOpenSessionTerminal(
26273
+ restoredEntry.session_id,
26274
+ { timeoutMs }
26275
+ );
26276
+ if (terminal.state === "failed") {
26277
+ const failure = {
26278
+ session_id: restoredEntry.session_id,
26279
+ agent_id: record.agent_id,
26280
+ provider: record.provider,
26281
+ reason: terminal.reason,
26282
+ };
26283
+ sessionManager.markDesiredOpenRestoreFailure(
26284
+ restoredEntry.session_id,
26285
+ {
26286
+ ...failure,
26287
+ reconciliationId,
26288
+ }
26289
+ );
26290
+ aggregate.failed.push(failure);
26291
+ } else {
26292
+ aggregate.restored.push(restoredEntry);
26293
+ }
26294
+ }
26295
+ }
26296
+ };
26297
+ await Promise.all(
26298
+ Array.from(
26299
+ { length: Math.min(DESIRED_OPEN_RESTORE_MAX_CONCURRENCY, queue.length) },
26300
+ () => worker()
26301
+ )
26302
+ );
26303
+
26304
+ if (aggregate.restored.length > 0) {
26305
+ await restoreTelegramListenersAfterHostRestart({
26306
+ snapshot: {},
26307
+ sessionRestore: aggregate,
26308
+ });
26309
+ for (const entry of aggregate.restored) {
26310
+ const session = sessionManager.getSession(entry.session_id);
26311
+ if (!session?.cwd) continue;
26312
+ try {
26313
+ const resolved = resolveAgentRuntimeConfig(session.cwd);
26314
+ session.notificationConfig = buildResolvedSessionNotificationConfig(
26315
+ resolved.layers
26316
+ );
26317
+ session.notificationConfigUpdatedAt = new Date().toISOString();
26318
+ } catch (err) {
26319
+ session.bookkeepingWarnings = [
26320
+ ...(session.bookkeepingWarnings || []),
26321
+ `desired_open_restore.notification_config_failed: ${err.message}`,
26322
+ ];
26323
+ }
26324
+ }
26325
+ }
26326
+
26327
+ const failedCount = aggregate.failed.length;
26328
+ for (const failure of aggregate.failed) {
26329
+ if (!failure.session_id) continue;
26330
+ const finalizedFailure = sessionManager.finalizeDesiredOpenRestoreFailure(
26331
+ failure.session_id,
26332
+ failure
26333
+ );
26334
+ try {
26335
+ updateSessionRecord(failure.session_id, {
26336
+ restore_failed_reason: finalizedFailure.reason,
26337
+ restore_failed_at: new Date().toISOString(),
26338
+ });
26339
+ } catch (err) {
26340
+ console.warn(
26341
+ `[desired-open-restore] Could not persist failure history ${failure.session_id}: ${err.message}`
26342
+ );
26343
+ }
26344
+ sessionCollectionAdmittedSessionIds.delete(failure.session_id);
26345
+ if (
26346
+ failure.reason === "restore_timeout" &&
26347
+ sessionManager.getSession(failure.session_id)
26348
+ ) {
26349
+ try {
26350
+ await sessionManager.killSession(failure.session_id);
26351
+ } catch (err) {
26352
+ console.warn(
26353
+ `[desired-open-restore] Could not stop timed-out runtime ${failure.session_id}: ${err.message}`
26354
+ );
26355
+ }
26356
+ }
26357
+ }
26358
+ commitSessionCollectionChange(
26359
+ failedCount > 0
26360
+ ? ["restore", "restore_failed_history"]
26361
+ : ["restore"],
26362
+ queue.map((entry) => entry.session_id)
26363
+ );
26364
+ lastDesiredOpenRestoreStatus = {
26365
+ ...lastDesiredOpenRestoreStatus,
26366
+ status: failedCount > 0 ? "completed_with_failures" : "completed",
26367
+ running: false,
26368
+ completed_at: new Date().toISOString(),
26369
+ counts: {
26370
+ desired: queue.length,
26371
+ restored: aggregate.restored.length,
26372
+ skipped: aggregate.skipped.length,
26373
+ failed: failedCount,
26374
+ },
26375
+ result: aggregate,
26376
+ };
26377
+ return buildDesiredOpenRestoreStatusPayload();
26378
+ }
26379
+
26380
+ async function reconcileDesiredOpenSessions({
26381
+ trigger,
26382
+ sessionIds = null,
26383
+ timeoutMs = DESIRED_OPEN_RESTORE_SESSION_TIMEOUT_MS,
26384
+ }) {
26385
+ while (desiredOpenReconciliationPromise) {
26386
+ try {
26387
+ await desiredOpenReconciliationPromise;
26388
+ } catch {
26389
+ // A queued exact retry owns a new reconciliation after the prior result.
26390
+ }
26391
+ }
26392
+ const reconciliation = executeDesiredOpenReconciliation({
26393
+ trigger,
26394
+ sessionIds,
26395
+ timeoutMs,
26396
+ });
26397
+ desiredOpenReconciliationPromise = reconciliation;
26398
+ try {
26399
+ return await reconciliation;
26400
+ } finally {
26401
+ if (desiredOpenReconciliationPromise === reconciliation) {
26402
+ desiredOpenReconciliationPromise = null;
26403
+ }
26404
+ }
26405
+ }
26406
+
24798
26407
  async function reconcileHostRestartRestoreOnBoot() {
24799
26408
  const existing = readHostRestartRestoreState({ configDir: getConfigDir() });
24800
26409
  const activeStack = getActiveStackName();
@@ -24808,6 +26417,30 @@ async function reconcileHostRestartRestoreOnBoot() {
24808
26417
  consumed: false,
24809
26418
  restart_restore: buildHostRestartRestoreSummary(existing),
24810
26419
  };
26420
+ if (readConfig().restore_previous_sessions_after_restart !== false) {
26421
+ void reconcileDesiredOpenSessions({ trigger: "host_boot" }).catch(
26422
+ (err) => {
26423
+ lastDesiredOpenRestoreStatus = {
26424
+ status: "failed",
26425
+ trigger: "host_boot",
26426
+ reconciliation_id: null,
26427
+ running: false,
26428
+ started_at: null,
26429
+ completed_at: new Date().toISOString(),
26430
+ error: err.message,
26431
+ };
26432
+ }
26433
+ );
26434
+ } else {
26435
+ lastDesiredOpenRestoreStatus = {
26436
+ status: "disabled_by_preference",
26437
+ trigger: "host_boot",
26438
+ reconciliation_id: null,
26439
+ running: false,
26440
+ started_at: null,
26441
+ completed_at: new Date().toISOString(),
26442
+ };
26443
+ }
24811
26444
  return lastHostRestartRestoreStatus;
24812
26445
  }
24813
26446
  const snapshot = existing.runtime_snapshot || {};
@@ -24837,6 +26470,7 @@ async function reconcileHostRestartRestoreOnBoot() {
24837
26470
  restoredSessionIds: sessionRestore.restored.map(
24838
26471
  (entry) => entry.session_id
24839
26472
  ),
26473
+ loopRestorePolicy: existing.loop_restore_policy || "default_off",
24840
26474
  }
24841
26475
  );
24842
26476
  const schedulerRestore = schedulerService.markHostRestartInterruptedRuns({
@@ -24904,6 +26538,16 @@ async function reconcileHostRestartRestoreOnBoot() {
24904
26538
  }
24905
26539
 
24906
26540
  await reconcileHostRestartRestoreOnBoot();
26541
+ try {
26542
+ const callbackReconciliation = await callbackHostIntegration.reconcilePending({
26543
+ pageSize: 100,
26544
+ });
26545
+ console.log(
26546
+ `[p238-callback] startup reconciliation considered ${callbackReconciliation.considered_count} callback(s)`
26547
+ );
26548
+ } catch (err) {
26549
+ console.warn(`[p238-callback] startup reconciliation failed: ${err.message || err}`);
26550
+ }
24907
26551
  removeObsoleteAgentRegistry(getConfigDir());
24908
26552
  browserRootProjectIndex.start();
24909
26553
  schedulerRunner.start();
@@ -25030,6 +26674,12 @@ server.listen(PORT, () => {
25030
26674
  `[oysterun-host] Runtime metadata write failed: ${err.message}`
25031
26675
  );
25032
26676
  }
26677
+ // A retained route-pending incident must not require a human login to begin
26678
+ // its read-only requalification. The external coordinator owns completion.
26679
+ setImmediate(() => {
26680
+ try { maybeCloseSupersededOysterunUpdateRecovery(); }
26681
+ catch (error) { console.warn("[lifecycle] Retained operation observation failed:", sanitizeLifecycleDiagnostic(error.message)); }
26682
+ });
25033
26683
  console.log(`[oysterun-host] Listening on http://localhost:${PORT}`);
25034
26684
  console.log(
25035
26685
  `[oysterun-host] WebSocket at ws://localhost:${PORT}/session/stream`