create-walle 0.10.2 → 0.10.3

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 (488) hide show
  1. package/README.md +3 -1
  2. package/lib/release-schema.json +2 -2
  3. package/lib/runtime-closure-policy.json +8 -7
  4. package/package.json +1 -1
  5. package/template/coding-task-manager/api-prompts.js +45 -7
  6. package/template/coding-task-manager/bin/claude-hook.js +55 -1
  7. package/template/coding-task-manager/bin/ctm-relay-client.js +35 -12
  8. package/template/coding-task-manager/bin/land.js +2069 -0
  9. package/template/coding-task-manager/bin/memory-census.js +4 -2
  10. package/template/coding-task-manager/bin/memory-sync-verify.js +151 -0
  11. package/template/coding-task-manager/bin/probe-refusal-scope.js +530 -0
  12. package/template/coding-task-manager/db.js +156 -32
  13. package/template/coding-task-manager/lib/acp/acp-client.js +383 -0
  14. package/template/coding-task-manager/lib/acp/dsh-context-truth.js +176 -0
  15. package/template/coding-task-manager/lib/acp/dsh-launch-model.js +302 -0
  16. package/template/coding-task-manager/lib/acp/dsh-mcp-servers.js +194 -0
  17. package/template/coding-task-manager/lib/acp/dsh-prompt-lane.js +616 -0
  18. package/template/coding-task-manager/lib/acp/dsh-route-patch.js +308 -0
  19. package/template/coding-task-manager/lib/acp/dsh-session-runtime.js +522 -0
  20. package/template/coding-task-manager/lib/acp/dsh-transcript.js +395 -0
  21. package/template/coding-task-manager/lib/acp/dsh-usage-log.js +162 -0
  22. package/template/coding-task-manager/lib/acp/ndjson-stdio-transport.js +225 -0
  23. package/template/coding-task-manager/lib/adopted-host-redial.js +47 -11
  24. package/template/coding-task-manager/lib/adopted-launch-environment.js +123 -0
  25. package/template/coding-task-manager/lib/agent-auto-update-runtime.js +0 -3
  26. package/template/coding-task-manager/lib/agent-auto-update.js +26 -10
  27. package/template/coding-task-manager/lib/agent-bundle/build.js +657 -0
  28. package/template/coding-task-manager/lib/agent-bundle/codex-install.js +558 -0
  29. package/template/coding-task-manager/lib/agent-bundle/drift.js +120 -0
  30. package/template/coding-task-manager/lib/agent-bundle/gc.js +522 -0
  31. package/template/coding-task-manager/lib/agent-bundle/inventory.js +346 -0
  32. package/template/coding-task-manager/lib/agent-bundle/push.js +717 -0
  33. package/template/coding-task-manager/lib/agent-bundle/skill-transfer.js +724 -0
  34. package/template/coding-task-manager/lib/agent-bundle/spawn.js +119 -0
  35. package/template/coding-task-manager/lib/agent-bundle/store.js +568 -0
  36. package/template/coding-task-manager/lib/agent-capabilities.js +142 -12
  37. package/template/coding-task-manager/lib/agent-cli-cache.js +30 -5
  38. package/template/coding-task-manager/lib/agent-definitions.js +19 -0
  39. package/template/coding-task-manager/lib/agent-hooks-codex-toml.js +28 -16
  40. package/template/coding-task-manager/lib/agent-hooks-codex-workspace.js +9 -1
  41. package/template/coding-task-manager/lib/agent-hooks-installer.js +23 -8
  42. package/template/coding-task-manager/lib/agent-hooks-prune.js +37 -11
  43. package/template/coding-task-manager/lib/agent-hooks-shim.js +105 -0
  44. package/template/coding-task-manager/lib/agent-presets.js +84 -1
  45. package/template/coding-task-manager/lib/agent-upgrade-jobs.js +12 -1
  46. package/template/coding-task-manager/lib/agent-version-service.js +74 -11
  47. package/template/coding-task-manager/lib/all-sessions-query.js +6 -2
  48. package/template/coding-task-manager/lib/api/agent-bundles.js +436 -0
  49. package/template/coding-task-manager/lib/api/backup-retention.js +83 -0
  50. package/template/coding-task-manager/lib/api/conversation-sync-routes.js +124 -0
  51. package/template/coding-task-manager/lib/api/diagnostics.js +549 -69
  52. package/template/coding-task-manager/lib/api/fleet.js +118 -0
  53. package/template/coding-task-manager/lib/api/hub-bridge.js +243 -0
  54. package/template/coding-task-manager/lib/api/ingress.js +9 -1
  55. package/template/coding-task-manager/lib/api/landing.js +403 -0
  56. package/template/coding-task-manager/lib/api/machines.js +691 -2
  57. package/template/coding-task-manager/lib/api/memory-sync-routes.js +335 -0
  58. package/template/coding-task-manager/lib/api/misc-ops.js +8 -3
  59. package/template/coding-task-manager/lib/api/remote-sessions.js +1326 -0
  60. package/template/coding-task-manager/lib/api/session-attach.js +623 -0
  61. package/template/coding-task-manager/lib/api/session-messages.js +1384 -0
  62. package/template/coding-task-manager/lib/api/sessions.js +77 -51
  63. package/template/coding-task-manager/lib/api/setup-network.js +544 -16
  64. package/template/coding-task-manager/lib/api/setup-wizard-handlers.js +884 -0
  65. package/template/coding-task-manager/lib/api/setup.js +829 -10
  66. package/template/coding-task-manager/lib/api/walle-bridge.js +9 -1
  67. package/template/coding-task-manager/lib/attachment-reconciler-wiring.js +5 -2
  68. package/template/coding-task-manager/lib/auth-rate-limit.js +23 -0
  69. package/template/coding-task-manager/lib/auth-rules.js +292 -21
  70. package/template/coding-task-manager/lib/background-stop.js +369 -0
  71. package/template/coding-task-manager/lib/best-of-attempts.js +4 -1
  72. package/template/coding-task-manager/lib/best-of-launch-plan.js +52 -0
  73. package/template/coding-task-manager/lib/box-keep-awake.js +89 -0
  74. package/template/coding-task-manager/lib/broadcast-reason-stats.js +152 -0
  75. package/template/coding-task-manager/lib/broadcast-serialize.js +5 -0
  76. package/template/coding-task-manager/lib/build-info.js +4 -2
  77. package/template/coding-task-manager/lib/claude-model-truth.js +13 -2
  78. package/template/coding-task-manager/lib/claude-transcript-terminal-renderer.js +17 -13
  79. package/template/coding-task-manager/lib/client-diag-handler.js +64 -2
  80. package/template/coding-task-manager/lib/codex-app-server-client.js +14 -16
  81. package/template/coding-task-manager/lib/codex-configuration-history.js +55 -0
  82. package/template/coding-task-manager/lib/codex-harness-qualification.js +43 -20
  83. package/template/coding-task-manager/lib/codex-rollout-history.js +743 -0
  84. package/template/coding-task-manager/lib/coding-agent-models.js +27 -12
  85. package/template/coding-task-manager/lib/coding-cli-skill-installer.js +44 -14
  86. package/template/coding-task-manager/lib/coding-session-model-adapters.js +43 -2
  87. package/template/coding-task-manager/lib/coding-session-model-owner.js +1 -1
  88. package/template/coding-task-manager/lib/coding-session-model-public.js +7 -1
  89. package/template/coding-task-manager/lib/coding-session-model-runtime.js +23 -4
  90. package/template/coding-task-manager/lib/coding-session-model-scan.js +31 -0
  91. package/template/coding-task-manager/lib/coding-session-model-state.js +7 -0
  92. package/template/coding-task-manager/lib/conversation-freshness-diagnostics.js +280 -0
  93. package/template/coding-task-manager/lib/conversation-item-clock.js +261 -0
  94. package/template/coding-task-manager/lib/conversation-source-registry.js +5 -0
  95. package/template/coding-task-manager/lib/conversation-store-v2.js +44 -0
  96. package/template/coding-task-manager/lib/conversation-sync.js +355 -0
  97. package/template/coding-task-manager/lib/create/resolve-launch-params.js +96 -12
  98. package/template/coding-task-manager/lib/create/worker-role-profile.js +4 -4
  99. package/template/coding-task-manager/lib/create-intent-lease.js +51 -0
  100. package/template/coding-task-manager/lib/ctm-session-context-api.js +97 -6
  101. package/template/coding-task-manager/lib/db-domain-tables.js +1 -0
  102. package/template/coding-task-manager/lib/db-domains.js +1 -0
  103. package/template/coding-task-manager/lib/db-owner-dispatch-probe.js +32 -0
  104. package/template/coding-task-manager/lib/db-owner-request-counts.js +178 -0
  105. package/template/coding-task-manager/lib/db-owner-worker-client.js +68 -14
  106. package/template/coding-task-manager/lib/db-schema-source-files.js +5 -0
  107. package/template/coding-task-manager/lib/deferred-restore-attempt.js +6 -2
  108. package/template/coding-task-manager/lib/feature-manifest.js +4 -0
  109. package/template/coding-task-manager/lib/fleet-conversation-route.js +97 -0
  110. package/template/coding-task-manager/lib/fleet-fanout.js +78 -0
  111. package/template/coding-task-manager/lib/fleet-hub.js +762 -0
  112. package/template/coding-task-manager/lib/fleet-origin-follower.js +252 -0
  113. package/template/coding-task-manager/lib/fleet-resolver-effects.js +147 -0
  114. package/template/coding-task-manager/lib/fleet-resolver-tick.js +148 -0
  115. package/template/coding-task-manager/lib/fleet-resolver.js +454 -0
  116. package/template/coding-task-manager/lib/fleet-session-list-routes.js +114 -0
  117. package/template/coding-task-manager/lib/fleet-session-lists.js +672 -0
  118. package/template/coding-task-manager/lib/fleet-update.js +798 -0
  119. package/template/coding-task-manager/lib/fs-watcher-root-watch.js +141 -0
  120. package/template/coding-task-manager/lib/fs-watcher.js +58 -42
  121. package/template/coding-task-manager/lib/harness-history-transition.js +50 -9
  122. package/template/coding-task-manager/lib/headless-term-service.js +84 -8
  123. package/template/coding-task-manager/lib/hermetic-agent-launch-guard.js +10 -0
  124. package/template/coding-task-manager/lib/history-transition-read-op.js +175 -8
  125. package/template/coding-task-manager/lib/host-cpu-categories.js +310 -0
  126. package/template/coding-task-manager/lib/host-fseventsd-sampler.js +399 -0
  127. package/template/coding-task-manager/lib/host-handoff.js +16 -19
  128. package/template/coding-task-manager/lib/host-stats.js +150 -3
  129. package/template/coding-task-manager/lib/hosted-sync-worker.js +43 -320
  130. package/template/coding-task-manager/lib/hub-bridge-access.js +798 -0
  131. package/template/coding-task-manager/lib/hub-bridge-box.js +409 -0
  132. package/template/coding-task-manager/lib/hub-bridge-mcp.js +380 -0
  133. package/template/coding-task-manager/lib/hub-bridge-mesh.js +313 -0
  134. package/template/coding-task-manager/lib/hub-bridge-ops.js +721 -0
  135. package/template/coding-task-manager/lib/hub-bridge.js +398 -0
  136. package/template/coding-task-manager/lib/idle-notify.js +491 -0
  137. package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +8 -0
  138. package/template/coding-task-manager/lib/instance-manifest.js +5 -1
  139. package/template/coding-task-manager/lib/instance-sync-status.js +18 -8
  140. package/template/coding-task-manager/lib/landing/agent-rule.txt +1 -0
  141. package/template/coding-task-manager/lib/landing/batch-runner.js +366 -0
  142. package/template/coding-task-manager/lib/landing/config.js +536 -0
  143. package/template/coding-task-manager/lib/landing/ctm-view.js +256 -0
  144. package/template/coding-task-manager/lib/landing/gate.js +360 -0
  145. package/template/coding-task-manager/lib/landing/git.js +344 -0
  146. package/template/coding-task-manager/lib/landing/lease.js +247 -0
  147. package/template/coding-task-manager/lib/landing/merge-delta.js +380 -0
  148. package/template/coding-task-manager/lib/landing/proof.js +223 -0
  149. package/template/coding-task-manager/lib/landing/queue.js +998 -0
  150. package/template/coding-task-manager/lib/landing/radar.js +489 -0
  151. package/template/coding-task-manager/lib/landing/resolve-cli.js +424 -0
  152. package/template/coding-task-manager/lib/landing/state.js +263 -0
  153. package/template/coding-task-manager/lib/landing/stop.js +49 -0
  154. package/template/coding-task-manager/lib/landing/sync-cli.js +475 -0
  155. package/template/coding-task-manager/lib/landing/sync.js +456 -0
  156. package/template/coding-task-manager/lib/landing/train.js +885 -0
  157. package/template/coding-task-manager/lib/landing/window.js +57 -0
  158. package/template/coding-task-manager/lib/launch-environment.js +121 -0
  159. package/template/coding-task-manager/lib/launch-model-override.js +36 -10
  160. package/template/coding-task-manager/lib/launch-plan-pool.js +2 -1
  161. package/template/coding-task-manager/lib/launch-plan-warm-cache.js +6 -0
  162. package/template/coding-task-manager/lib/lead-model-observer.js +57 -1
  163. package/template/coding-task-manager/lib/lead-model-options.js +160 -3
  164. package/template/coding-task-manager/lib/lead-model-restore-reconciliation.js +106 -24
  165. package/template/coding-task-manager/lib/lead-model-switch-runtime.js +352 -68
  166. package/template/coding-task-manager/lib/lead-model-switch.js +114 -10
  167. package/template/coding-task-manager/lib/lead-model-transition-runtime.js +14 -2
  168. package/template/coding-task-manager/lib/lead-model-transition-store.js +24 -6
  169. package/template/coding-task-manager/lib/lead-route-preflight.js +179 -0
  170. package/template/coding-task-manager/lib/lead-switch-history-mode.js +110 -0
  171. package/template/coding-task-manager/lib/lead-wire-agent.js +7 -0
  172. package/template/coding-task-manager/lib/lifecycle-wake-experiment.js +9 -2
  173. package/template/coding-task-manager/lib/live-defer-announce.js +45 -0
  174. package/template/coding-task-manager/lib/machine-registry.js +750 -5
  175. package/template/coding-task-manager/lib/macos-capabilities.js +32 -15
  176. package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +433 -53
  177. package/template/coding-task-manager/lib/mainloop-observability.js +14 -9
  178. package/template/coding-task-manager/lib/media-draft-owner.js +16 -3
  179. package/template/coding-task-manager/lib/memory-join.js +337 -0
  180. package/template/coding-task-manager/lib/memory-sync-verify.js +393 -0
  181. package/template/coding-task-manager/lib/memory-sync.js +565 -0
  182. package/template/coding-task-manager/lib/migrations/v57-delegate-pref-mirror-opencode.js +68 -0
  183. package/template/coding-task-manager/lib/migrations/v58-subagent-launch-native-subagents.js +42 -0
  184. package/template/coding-task-manager/lib/migrations/v59-coding-session-model-state-dsh.js +73 -0
  185. package/template/coding-task-manager/lib/migrations/v60-delegate-pref-mirror-dsh.js +78 -0
  186. package/template/coding-task-manager/lib/migrations/v61-conversation-item-clock.js +71 -0
  187. package/template/coding-task-manager/lib/model-availability.js +727 -0
  188. package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +4 -6
  189. package/template/coding-task-manager/lib/model-host-receipt.js +6 -3
  190. package/template/coding-task-manager/lib/model-id-rule.js +19 -0
  191. package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
  192. package/template/coding-task-manager/lib/model-proxy/auto-mode-review.js +110 -23
  193. package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
  194. package/template/coding-task-manager/lib/model-proxy/generation-probe.js +60 -17
  195. package/template/coding-task-manager/lib/model-proxy/historical-tool-declarations.js +37 -0
  196. package/template/coding-task-manager/lib/model-proxy/lead-request-validation.js +1 -0
  197. package/template/coding-task-manager/lib/model-proxy/lead-token-admin.js +130 -0
  198. package/template/coding-task-manager/lib/model-proxy/refusal-scope.js +151 -0
  199. package/template/coding-task-manager/lib/model-proxy/request-budget-store.js +11 -2
  200. package/template/coding-task-manager/lib/model-proxy/tokens.js +69 -5
  201. package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
  202. package/template/coding-task-manager/lib/native-agent-model-args.js +25 -22
  203. package/template/coding-task-manager/lib/native-history-control.js +6 -4
  204. package/template/coding-task-manager/lib/native-lead-settings-client.js +44 -4
  205. package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +173 -36
  206. package/template/coding-task-manager/lib/online-backup-worker-client.js +2 -0
  207. package/template/coding-task-manager/lib/opencode-cli.js +480 -0
  208. package/template/coding-task-manager/lib/opencode-launch-model.js +471 -0
  209. package/template/coding-task-manager/lib/opencode-v1-plugin/model-carrier.mjs +35 -0
  210. package/template/coding-task-manager/lib/opencode-v2-plugin/server.js +118 -0
  211. package/template/coding-task-manager/lib/owner-action-manifest-data.json +1 -1
  212. package/template/coding-task-manager/lib/park-composer-draft.js +54 -0
  213. package/template/coding-task-manager/lib/park-fresh-relaunch.js +41 -10
  214. package/template/coding-task-manager/lib/perf-phase-trace.js +72 -0
  215. package/template/coding-task-manager/lib/perf-summary.js +7 -0
  216. package/template/coding-task-manager/lib/perf-tracker.js +63 -2
  217. package/template/coding-task-manager/lib/project-config.js +7 -0
  218. package/template/coding-task-manager/lib/prompt-purge-v2.js +52 -1
  219. package/template/coding-task-manager/lib/provider-login-broker.js +72 -15
  220. package/template/coding-task-manager/lib/provider-login-tab.js +10 -2
  221. package/template/coding-task-manager/lib/read-pool-client.js +89 -2
  222. package/template/coding-task-manager/lib/read-pool-worker-handle.js +29 -4
  223. package/template/coding-task-manager/lib/recent-catalog-query.js +15 -0
  224. package/template/coding-task-manager/lib/recent-session-catalog-response.js +67 -10
  225. package/template/coding-task-manager/lib/recovery-evidence.js +26 -11
  226. package/template/coding-task-manager/lib/relay-client.js +57 -3
  227. package/template/coding-task-manager/lib/relink-audit.js +34 -5
  228. package/template/coding-task-manager/lib/remote-machine-catalog.js +321 -26
  229. package/template/coding-task-manager/lib/remote-machine-create.js +576 -36
  230. package/template/coding-task-manager/lib/remote-machine-link.js +546 -59
  231. package/template/coding-task-manager/lib/remote-machine-proxy.js +143 -9
  232. package/template/coding-task-manager/lib/remote-session-forward.js +65 -7
  233. package/template/coding-task-manager/lib/remote-terminal-target.js +9 -2
  234. package/template/coding-task-manager/lib/restart-guard.js +18 -6
  235. package/template/coding-task-manager/lib/restore-seed-modes.js +87 -0
  236. package/template/coding-task-manager/lib/restored-scrollback-hydration-runtime.js +22 -2
  237. package/template/coding-task-manager/lib/resume-admission.js +17 -3
  238. package/template/coding-task-manager/lib/review-repo-registry.js +58 -19
  239. package/template/coding-task-manager/lib/root-delivery-runtime.js +2 -2
  240. package/template/coding-task-manager/lib/root-prompt-flow.js +1 -1
  241. package/template/coding-task-manager/lib/runtime-contract.js +3 -1
  242. package/template/coding-task-manager/lib/schema-indexes.js +1 -0
  243. package/template/coding-task-manager/lib/session-context-locator.js +742 -0
  244. package/template/coding-task-manager/lib/session-create/attachment.js +2 -2
  245. package/template/coding-task-manager/lib/session-create/create-phases.js +118 -0
  246. package/template/coding-task-manager/lib/session-create/delegate-worker.js +86 -16
  247. package/template/coding-task-manager/lib/session-create/dsh-session.js +930 -0
  248. package/template/coding-task-manager/lib/session-create/lead-route-launch.js +186 -13
  249. package/template/coding-task-manager/lib/session-create/native-lead-lifetime.js +55 -3
  250. package/template/coding-task-manager/lib/session-create/opencode-lead-provider.js +50 -0
  251. package/template/coding-task-manager/lib/session-create/prepared.js +162 -38
  252. package/template/coding-task-manager/lib/session-create/provider-process.js +160 -28
  253. package/template/coding-task-manager/lib/session-create/response.js +7 -7
  254. package/template/coding-task-manager/lib/session-create/structured-session.js +27 -0
  255. package/template/coding-task-manager/lib/session-create/subagent-pick-table.js +3 -1
  256. package/template/coding-task-manager/lib/session-create/subagent-pick.js +458 -9
  257. package/template/coding-task-manager/lib/session-create/transcript.js +1 -1
  258. package/template/coding-task-manager/lib/session-create/worker-budget-setup.js +21 -2
  259. package/template/coding-task-manager/lib/session-create/worker-continuation.js +7 -1
  260. package/template/coding-task-manager/lib/session-create/worker-launch-sandbox.js +39 -10
  261. package/template/coding-task-manager/lib/session-create/worker-observability.js +2 -2
  262. package/template/coding-task-manager/lib/session-empty.js +13 -10
  263. package/template/coding-task-manager/lib/session-host-manager.js +60 -0
  264. package/template/coding-task-manager/lib/session-jobs.js +3 -1
  265. package/template/coding-task-manager/lib/session-lifecycle.js +10 -4
  266. package/template/coding-task-manager/lib/session-model-action-preconditions.js +6 -2
  267. package/template/coding-task-manager/lib/session-model-intent-contract.js +11 -8
  268. package/template/coding-task-manager/lib/session-park.js +738 -0
  269. package/template/coding-task-manager/lib/session-path-grant.js +13 -4
  270. package/template/coding-task-manager/lib/session-status-ledger.js +106 -4
  271. package/template/coding-task-manager/lib/session-transport.js +17 -0
  272. package/template/coding-task-manager/lib/session-workspace-projection.js +54 -5
  273. package/template/coding-task-manager/lib/session-workspace-tracking-runtime.js +213 -1
  274. package/template/coding-task-manager/lib/skill-autocomplete-query.js +120 -3
  275. package/template/coding-task-manager/lib/skill-autocomplete.js +15 -1
  276. package/template/coding-task-manager/lib/sqlite-file-set.js +39 -0
  277. package/template/coding-task-manager/lib/standup-search-adapter.js +16 -2
  278. package/template/coding-task-manager/lib/state-sync/client-ledger.js +12 -0
  279. package/template/coding-task-manager/lib/state-sync/frame-emitter.js +319 -100
  280. package/template/coding-task-manager/lib/storage/coding-session-model-store.js +4 -1
  281. package/template/coding-task-manager/lib/storage/delegate-pref-mirror-store.js +32 -9
  282. package/template/coding-task-manager/lib/structured-session-boot-sweep.js +235 -0
  283. package/template/coding-task-manager/lib/submission-ack.js +90 -2
  284. package/template/coding-task-manager/lib/systemd-scope.js +115 -0
  285. package/template/coding-task-manager/lib/telemetry-receiver.js +39 -0
  286. package/template/coding-task-manager/lib/terminal-attach-geometry.js +985 -0
  287. package/template/coding-task-manager/lib/terminal-bootstrap.js +30 -0
  288. package/template/coding-task-manager/lib/terminal-draft-operation.js +357 -0
  289. package/template/coding-task-manager/lib/terminal-input-admission.js +17 -1
  290. package/template/coding-task-manager/lib/terminal-input-prepared.js +563 -0
  291. package/template/coding-task-manager/lib/transcript-cache-temps.js +171 -0
  292. package/template/coding-task-manager/lib/transcript-frame-cache.js +8 -3
  293. package/template/coding-task-manager/lib/transcript-source.js +36 -4
  294. package/template/coding-task-manager/lib/transcript-store.js +9 -0
  295. package/template/coding-task-manager/lib/typing-interaction-trace.js +106 -3
  296. package/template/coding-task-manager/lib/usage-ledger/backfill.js +40 -5
  297. package/template/coding-task-manager/lib/usage-ledger/client.js +4 -1
  298. package/template/coding-task-manager/lib/usage-ledger/dsh-parse.js +152 -0
  299. package/template/coding-task-manager/lib/usage-ledger/dsh-read.js +73 -0
  300. package/template/coding-task-manager/lib/usage-ledger/ingest.js +34 -6
  301. package/template/coding-task-manager/lib/usage-ledger/live.js +35 -8
  302. package/template/coding-task-manager/lib/usage-ledger/read.js +29 -20
  303. package/template/coding-task-manager/lib/usage-ledger/schema.js +61 -1
  304. package/template/coding-task-manager/lib/usage-ledger/task-coordinator.js +4 -2
  305. package/template/coding-task-manager/lib/wake-skip-reasons.js +1 -0
  306. package/template/coding-task-manager/lib/walle/turn.js +1 -1
  307. package/template/coding-task-manager/lib/walle-history-serve.js +838 -0
  308. package/template/coding-task-manager/lib/walle-kill-authority.js +2 -2
  309. package/template/coding-task-manager/lib/walle-log-rotation.js +164 -1
  310. package/template/coding-task-manager/lib/walle-mcp-auto-config.js +56 -2
  311. package/template/coding-task-manager/lib/walle-row-history.js +495 -0
  312. package/template/coding-task-manager/lib/walle-supervisor.js +33 -14
  313. package/template/coding-task-manager/lib/watch-worker-client.js +203 -0
  314. package/template/coding-task-manager/lib/worker-terminal-output.js +5 -2
  315. package/template/coding-task-manager/lib/ws/hub-bridge.js +35 -0
  316. package/template/coding-task-manager/lib/ws/lifecycle.js +6 -11
  317. package/template/coding-task-manager/lib/ws/misc.js +1 -1
  318. package/template/coding-task-manager/lib/ws/state-sync.js +106 -4
  319. package/template/coding-task-manager/lib/ws/terminal-attach.js +91 -0
  320. package/template/coding-task-manager/oauth-proxy.js +112 -115
  321. package/template/coding-task-manager/package-lock.json +10 -94
  322. package/template/coding-task-manager/package.json +4 -3
  323. package/template/coding-task-manager/protocol/message-table.json +10 -0
  324. package/template/coding-task-manager/protocol/messages.js +10 -0
  325. package/template/coding-task-manager/protocol/ws-schema.json +20 -0
  326. package/template/coding-task-manager/protocol/ws-schema.md +2 -0
  327. package/template/coding-task-manager/public/css/app.css +170 -0
  328. package/template/coding-task-manager/public/css/walle-session.css +4 -0
  329. package/template/coding-task-manager/public/index.html +473 -243
  330. package/template/coding-task-manager/public/js/agent-command.js +86 -0
  331. package/template/coding-task-manager/public/js/app/backups-panel.js +111 -5
  332. package/template/coding-task-manager/public/js/app/coding-agent-upgrade.js +103 -4
  333. package/template/coding-task-manager/public/js/app/machines-page.js +1328 -8
  334. package/template/coding-task-manager/public/js/app/models-tab.js +113 -38
  335. package/template/coding-task-manager/public/js/app/restart-banner.js +53 -0
  336. package/template/coding-task-manager/public/js/app/worktrees-panel.js +184 -2
  337. package/template/coding-task-manager/public/js/background-work.js +196 -27
  338. package/template/coding-task-manager/public/js/coding-session-model-presentation.js +138 -10
  339. package/template/coding-task-manager/public/js/core/active-session-groups.js +179 -0
  340. package/template/coding-task-manager/public/js/core/pending-create.js +238 -0
  341. package/template/coding-task-manager/public/js/core/recent-machine-source.js +228 -0
  342. package/template/coding-task-manager/public/js/core/session-commands.js +180 -14
  343. package/template/coding-task-manager/public/js/dashboard-client-function-order.json +8 -0
  344. package/template/coding-task-manager/public/js/document-review-links.js +9 -1
  345. package/template/coding-task-manager/public/js/file-resolution-client.js +52 -1
  346. package/template/coding-task-manager/public/js/history-app-seam.js +22 -2
  347. package/template/coding-task-manager/public/js/machine-pool-shared.js +558 -3
  348. package/template/coding-task-manager/public/js/message-renderer.js +4 -3
  349. package/template/coding-task-manager/public/js/model-picker.js +3 -1
  350. package/template/coding-task-manager/public/js/native-subagent-review.js +15 -2
  351. package/template/coding-task-manager/public/js/new-session-agent-grid.js +346 -0
  352. package/template/coding-task-manager/public/js/pasted-content.js +236 -0
  353. package/template/coding-task-manager/public/js/pe-private-content-lifetime.js +39 -7
  354. package/template/coding-task-manager/public/js/private-model-lifetime.js +6 -2
  355. package/template/coding-task-manager/public/js/prompts.js +11 -10
  356. package/template/coding-task-manager/public/js/protocol.js +10 -0
  357. package/template/coding-task-manager/public/js/restart-banner-policy.js +29 -1
  358. package/template/coding-task-manager/public/js/session-model-picker.js +447 -57
  359. package/template/coding-task-manager/public/js/session-search-utils.js +18 -2
  360. package/template/coding-task-manager/public/js/session-usage-view.js +2 -1
  361. package/template/coding-task-manager/public/js/session-workspace-tracking.js +51 -1
  362. package/template/coding-task-manager/public/js/state-sync-client.js +10 -1
  363. package/template/coding-task-manager/public/js/stream-view.js +6 -5
  364. package/template/coding-task-manager/public/js/terminal/conv-only-session.js +287 -0
  365. package/template/coding-task-manager/public/js/terminal/runtime-09.js +2 -0
  366. package/template/coding-task-manager/public/js/terminal/runtime-10.js +5 -5
  367. package/template/coding-task-manager/public/js/terminal/runtime-12.js +8 -1
  368. package/template/coding-task-manager/public/js/terminal/runtime-16.js +4 -0
  369. package/template/coding-task-manager/public/js/terminal/runtime-20.js +1 -1
  370. package/template/coding-task-manager/public/js/terminal-attachment-draft.js +67 -13
  371. package/template/coding-task-manager/public/js/terminal-image-orchestration.js +52 -6
  372. package/template/coding-task-manager/public/js/terminal-wheel.js +23 -3
  373. package/template/coding-task-manager/public/js/walle/chat-conversation.js +4 -4
  374. package/template/coding-task-manager/public/js/walle.js +6 -0
  375. package/template/coding-task-manager/public/js/ws/live-dispatch.js +10 -0
  376. package/template/coding-task-manager/public/m/app.css +140 -0
  377. package/template/coding-task-manager/public/m/app.js +507 -68
  378. package/template/coding-task-manager/public/m/index.html +17 -0
  379. package/template/coding-task-manager/public/m/machines.js +838 -4
  380. package/template/coding-task-manager/public/m/private-api.js +31 -1
  381. package/template/coding-task-manager/public/usage.html +1 -0
  382. package/template/coding-task-manager/scripts/dev-route-fixture/synthetic-route-upstream.js +104 -0
  383. package/template/coding-task-manager/scripts/dev-route-fixture.js +339 -0
  384. package/template/coding-task-manager/scripts/opencode-lead-route-probe.js +195 -0
  385. package/template/coding-task-manager/scripts/opencode-v2-contract-probe/fake-anthropic.js +168 -0
  386. package/template/coding-task-manager/scripts/opencode-v2-contract-probe/probe-plugin/server.js +95 -0
  387. package/template/coding-task-manager/scripts/opencode-v2-contract-probe.js +673 -0
  388. package/template/coding-task-manager/server.js +1950 -11679
  389. package/template/coding-task-manager/session-integrity.js +64 -4
  390. package/template/coding-task-manager/workers/db-owner-worker.js +19 -2
  391. package/template/coding-task-manager/workers/online-backup-worker.js +1 -0
  392. package/template/coding-task-manager/workers/read-pool-model-collector.js +51 -3
  393. package/template/coding-task-manager/workers/read-pool-worker.js +76 -6
  394. package/template/coding-task-manager/workers/session-host-pool-process.js +6 -6
  395. package/template/coding-task-manager/workers/session-host-process.js +5 -5
  396. package/template/coding-task-manager/workers/watch-worker.js +105 -0
  397. package/template/coding-task-manager/workers/worktree-worker.js +168 -33
  398. package/template/ctm-relay/lib/protocol.js +14 -1
  399. package/template/package-lock.json +2 -2
  400. package/template/package.json +1 -1
  401. package/template/shared/backup-retention.js +82 -0
  402. package/template/shared/conversation-admission.js +2 -0
  403. package/template/shared/conversation-provenance.js +3 -7
  404. package/template/shared/conversation-storage-v2-contract.js +5 -0
  405. package/template/shared/linked-worktree.js +51 -0
  406. package/template/shared/macos-fda-target.js +57 -0
  407. package/template/shared/orphan-parents.js +55 -0
  408. package/template/shared/release-schema.json +2 -2
  409. package/template/shared/storage/contract.js +3 -0
  410. package/template/wall-e/adapters/imessage.js +12 -0
  411. package/template/wall-e/agent.js +71 -4
  412. package/template/wall-e/api-walle.js +21 -1
  413. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +2 -2
  414. package/template/wall-e/bin/walle-mcp-stdio.js +5 -5
  415. package/template/wall-e/brain/brain-join.js +859 -0
  416. package/template/wall-e/brain/brain-sync-membership.js +20 -0
  417. package/template/wall-e/brain/brain-sync-store.js +131 -77
  418. package/template/wall-e/brain/brain-sync-v2-schema.js +203 -0
  419. package/template/wall-e/brain/brain-sync-v2.js +578 -0
  420. package/template/wall-e/brain/delegate-model-prefs-store.js +13 -4
  421. package/template/wall-e/brain/deterministic-id.js +51 -0
  422. package/template/wall-e/brain/memories.js +2 -2
  423. package/template/wall-e/brain/migrations.js +40 -6
  424. package/template/wall-e/brain/model-identity-migration.js +1 -1
  425. package/template/wall-e/brain/owner-call-whitelist.js +23 -7
  426. package/template/wall-e/brain/schema.js +45 -85
  427. package/template/wall-e/brain.js +171 -35
  428. package/template/wall-e/chat.js +3 -3
  429. package/template/wall-e/coding/agent-shell-env.js +7 -2
  430. package/template/wall-e/coding/prompt-bundle.js +66 -21
  431. package/template/wall-e/config/main-loop-exemptions.json +2 -2
  432. package/template/wall-e/http/admin-api.js +37 -0
  433. package/template/wall-e/http/gws-api.js +4 -0
  434. package/template/wall-e/http/helper-rules-api.js +7 -1
  435. package/template/wall-e/http/model-admin.js +8 -1
  436. package/template/wall-e/http/model-delegate-pref-admin.js +3 -3
  437. package/template/wall-e/http/model-proxy-providers.js +174 -1
  438. package/template/wall-e/http/model-route-health.js +157 -0
  439. package/template/wall-e/http/sync-api.js +278 -464
  440. package/template/wall-e/jobs/job-runner.js +28 -1
  441. package/template/wall-e/lib/brain-owner-op-priority.js +9 -1
  442. package/template/wall-e/lib/ctm-data-dir.js +26 -0
  443. package/template/wall-e/lib/derivation-home.js +147 -0
  444. package/template/wall-e/lib/helper-rules/evaluate.js +50 -9
  445. package/template/wall-e/lib/helper-rules/grade.js +51 -8
  446. package/template/wall-e/lib/helper-rules/lead-call-hook.js +38 -5
  447. package/template/wall-e/lib/helper-rules/store.js +4 -2
  448. package/template/wall-e/lib/landing-mcp-tools.js +170 -0
  449. package/template/wall-e/lib/mcp-client-config.js +133 -14
  450. package/template/wall-e/lib/mcp-gateway-migrate.js +28 -64
  451. package/template/wall-e/lib/mcp-integration.js +58 -13
  452. package/template/wall-e/lib/parent-death.js +7 -1
  453. package/template/wall-e/lib/scheduler-worker-jobs.js +6 -4
  454. package/template/wall-e/llm/delegate-lead-prefs.js +16 -1
  455. package/template/wall-e/llm/delegate-vendor-default.js +12 -5
  456. package/template/wall-e/llm/model-roles.js +1 -1
  457. package/template/wall-e/llm/native-delegate-selection.js +56 -8
  458. package/template/wall-e/llm/worker-delegate-args.js +72 -7
  459. package/template/wall-e/llm/worker-mcp-routing.js +66 -17
  460. package/template/wall-e/loops/brain-custom-writes.js +19 -0
  461. package/template/wall-e/loops/ingest.js +17 -1
  462. package/template/wall-e/loops/think.js +19 -4
  463. package/template/wall-e/mcp-contract.js +222 -6
  464. package/template/wall-e/mcp-protocol-contract.js +40 -5
  465. package/template/wall-e/mcp-server.js +128 -29
  466. package/template/wall-e/mcp-worker-schemas.js +5 -5
  467. package/template/wall-e/memory/ctm-context-client.js +85 -0
  468. package/template/wall-e/memory/ctm-session-context.js +9 -3
  469. package/template/wall-e/memory/hub-bridge-client.js +388 -0
  470. package/template/wall-e/memory/replica-origin.js +56 -0
  471. package/template/wall-e/memory/session-pool.js +135 -0
  472. package/template/wall-e/runtime/live-capabilities.js +8 -3
  473. package/template/wall-e/server.js +4 -2
  474. package/template/wall-e/shared/release-schema.json +2 -2
  475. package/template/wall-e/shared/sqlite-file-set.js +39 -0
  476. package/template/wall-e/shared/storage/contract.js +3 -0
  477. package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
  478. package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +7 -1
  479. package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +4 -4
  480. package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +6 -6
  481. package/template/wall-e/skills/_templates/fleet-sync/VERSION +1 -0
  482. package/template/wall-e/skills/_templates/fleet-sync/released-sha256.json +16 -0
  483. package/template/wall-e/skills/_templates/fleet-sync/templates/claude/SKILL.md +64 -0
  484. package/template/wall-e/skills/_templates/fleet-sync/templates/codex/SKILL.md +64 -0
  485. package/template/wall-e/tools/chrome-automation.js +6 -0
  486. package/template/wall-e/training/setup-cuda-train-env.sh +2 -2
  487. package/template/wall-e/workers/brain-owner-worker.js +18 -4
  488. package/template/wall-e/workers/mcp-gateway-process.js +5 -5
@@ -0,0 +1,1384 @@
1
+ 'use strict';
2
+
3
+ // Landing-throughput WP-5 shard S3 (design doc §5 D-10 "move-only", §6 "Shard",
4
+ // module seam", §8 WP-5, §9 S-21..S-27): the session-messages API cluster moves out
5
+ // of server.js VERBATIM into this module behind the same configure(deps) seam as
6
+ // lib/api/setup-network.js. It registers NO routes -- the routes stay in
7
+ // lib/api/sessions.js, whose configure() call in server.js now receives
8
+ // sessionMessagesApi.apiSessionMessages / sessionMessagesApi.apiGenerateTitles.
9
+ // Each moved body is identical to server.js's declaration except for the `_deps.`
10
+ // prefix on every identifier that used to resolve to a server.js top-level binding
11
+ // (D-10 rule 1), proven by `node bin/move-only-check.js`.
12
+ //
13
+ // Injected deps, per function:
14
+ // apiSessionMessages: SESSION_ID_RE, _RESPONSE_CONTRACT_RANGE_METRICS, _RESPONSE_CONTRACT_STATUS, _RESPONSE_HISTORY_TIMING, _buildConversationPageV4OffThread, _buildSessionMessagesPageResponseFromRowsOffThread, _convFailSoftEnabled, _focusConversationRecovery, _kickUiRowMissImport, _loadSessionMessageExclusionsOffThread, _peekSessionMessageExclusionsMemo, _queueConversationCatchUpIfNeeded, _resolveCanonicalRowSources, _sessionHasCodexMessageSource, _sessionMessageRowsHwmOffThread, _sessionMessagesForegroundBreaker, _sessionRowsFreshnessForSource, _standupStatusText, _walleHistoryHttpCorrelationFromUrl, dbModule, getSession
15
+ // _computePromptMessagesFromFreshestSource: CTM_PROMPT_INDEX_RESOLVE_IN_WORKER, LARGE_SESSION_TAIL_READ_BYTES, _applySessionMessageExclusionsFromRows, _catchUpTranscriptStoreForSession, _computeUserPromptIndexOffThread, _loadSessionMessageExclusionsOffThread, _mergeSessionMessagesWithStreamTail, _messageIndexPromptSourceIds, _perfTracker, _promptRowsFreshness, _promptSummaryFromMessages, _resolveAllSessionFilesOffThread, _sessionHasCodexMessageSource, _sourceWithStreamTail, promptIdentityKey, streamEventToMessage
16
+ // _apiRecentSessionsLegacy: CTM_RECENT_SERIALIZE_CACHE_ENABLED, HOSTNAME, _RECENT_SERIALIZE_CACHE_TTL_MS, _RECENT_SESSIONS_CACHE_TTL, _apiAgentSessionsFromDbRow, _controlSnapshot, _dedupeRecentSessionsByCanonicalId, _getCachedStarredSessionIds, _inRestoreStorm, _isProviderChildAgentRow, _isProviderChildSessionRow, _loadPersistedRecentSessionsCache, _mainTranscriptReads, _perfTracker, _recentSessionActivityMs, _recentSessionFromDbRow, _recentSessionRowsMapCache, _recentSessionsCache, _recentSessionsCacheTime, _recentSessionsPersistLoaded, _recentSessionsSerializedCache, _recentTranscriptPathsForRows, _scheduleRecentSessionsFullRescan, _scheduleRecentSessionsRebuild, _shouldIncludeDbOnlyRecentSession, _starredIdsCacheAt, dbModule, dbRowAgentSessionIds, maxUserMsgCount, remoteMachineCatalog, sessionEmpty
17
+ // buildSessionSearchResponse: CTM_SEARCH_SEMANTIC_MIN_LEXICAL_RESULTS, SessionSearchUtils, _analysisFtsSearchRows, _apiAgentSessionsFromDbRow, _buildSearchSessionMap, _canonicalSearchSessionId, _dedupeSearchRows, _emptySessionSearchResponse, _groupMessageResults, _likeSearchMetadata, _loadSearchCorpusSessions, _mergeSearchSources, _primaryApiAgentSession, _scoreTranscriptEventFtsRows, _searchResultTitleFields, _searchSessionFtsCorpusOffThread, _semanticSessionSearch, _sessionCatalogForSearch, _sessionReferenceSearch, _sessionSearchPlan, _skippedSessionSearchSemanticStatus, dbRowAgentSessionIds, maxUserMsgCount, requestConversationImport
18
+ // apiGenerateTitles: _readSessionCatalogOffThread, callClaudeAsync, cleanFirstMessage, dbModule, isCtmInternalSession, isHumanPromptCandidate, persistTitleStatus, titleGenerationFailureInfo
19
+ //
20
+ // Mutable module state is injected as GETTERS (D-10 rule 2) -- _recentSessionRowsMapCache, _recentSessionsCache, _recentSessionsCacheTime, _recentSessionsPersistLoaded, _recentSessionsSerializedCache, _starredIdsCacheAt.
21
+ // The two the moved bodies also ASSIGN to are injected as getter/setter pairs
22
+ // (_recentSessionsPersistLoaded, _recentSessionsSerializedCache), so the write reaches the same server.js
23
+ // binding instead of a copy frozen at configure() time.
24
+ //
25
+ // apiSessionMessages' four inline `require('./lib/...')` calls are rebased to the
26
+ // module's own static require('../...') (S1 precedent: injecting server.js's
27
+ // `require` is a loader-value transfer the public runtime closure guard refuses).
28
+ // That is the ONE deviation from a pure move-only diff; the checker reports it.
29
+
30
+ let _deps = null;
31
+
32
+ function configure(deps = {}) {
33
+ _deps = deps;
34
+ }
35
+
36
+ async function apiSessionMessages(req, res, url) {
37
+ void req;
38
+ const Protocol = require('../conversation-read-protocol');
39
+ const Cursor = require('../conversation-page-cursor');
40
+ const sendJson = (statusCode, body, headers = {}) => {
41
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', ...headers });
42
+ res.end(JSON.stringify(body));
43
+ };
44
+ const sendBuffer = (statusCode, buffer, headers = {}) => {
45
+ const timing = buffer?.[_deps._RESPONSE_HISTORY_TIMING];
46
+ const boundedDuration = (value) => Math.max(0,
47
+ Math.round((Number(value) || 0) * 10) / 10);
48
+ const timingHeaders = timing ? {
49
+ 'Server-Timing': [
50
+ `ctm_history;dur=${boundedDuration(timing.totalMs)}`,
51
+ `ctm_queue;dur=${boundedDuration(timing.queueMs)}`,
52
+ `ctm_query;dur=${boundedDuration(timing.queryMs)}`,
53
+ `ctm_render;dur=${boundedDuration(timing.renderMs)}`,
54
+ ].join(', '),
55
+ 'X-CTM-History-Total-Ms': String(boundedDuration(timing.totalMs)),
56
+ } : {};
57
+ res.writeHead(statusCode, {
58
+ 'Content-Type': 'application/json',
59
+ ...headers,
60
+ ...timingHeaders,
61
+ });
62
+ res.end(buffer);
63
+ };
64
+
65
+ const sessionId = String(url.searchParams.get('id') || '').trim();
66
+ const historyHttpCorrelation = _deps._walleHistoryHttpCorrelationFromUrl(url);
67
+ if (historyHttpCorrelation) {
68
+ const correlation = historyHttpCorrelation.fields;
69
+ res.setHeader('X-CTM-History-Request-Id', String(correlation.historyRequestId || 0));
70
+ res.setHeader('X-CTM-History-Contract-Version', String(correlation.historyContractVersion || 0));
71
+ res.setHeader('X-CTM-History-Cycle-Id', String(correlation.historyCycleId || 0));
72
+ res.setHeader('X-CTM-History-Mode', correlation.historyMode);
73
+ res.setHeader('X-CTM-History-Cursor', encodeURIComponent(correlation.requestedCursor || ''));
74
+ res.setHeader('X-CTM-History-Requested-Generation', String(correlation.requestedGeneration));
75
+ res.setHeader(
76
+ 'X-CTM-History-Requested-Ordering-Generation',
77
+ String(correlation.requestedOrderingGeneration),
78
+ );
79
+ // H2 controller incarnation echo (same-realm correlation, never an auth/proof claim):
80
+ // emitted ONLY when the request carried the marker own. A valid marker echoes its canonical
81
+ // decimal text; the explicit null sentinel is labeled 'invalid' -- never '0', never empty,
82
+ // never undefined and never an omission. A truly markerless request adds no header at all.
83
+ if (Object.prototype.hasOwnProperty.call(correlation, 'historyControllerInstanceId')) {
84
+ res.setHeader(
85
+ 'X-CTM-History-Controller-Instance-Id',
86
+ correlation.historyControllerInstanceId === null
87
+ ? 'invalid'
88
+ : String(correlation.historyControllerInstanceId),
89
+ );
90
+ }
91
+ if (historyHttpCorrelation.error) {
92
+ sendJson(400, {
93
+ ...Protocol.conversationErrorEnvelope({
94
+ status: 'invalid_request', reason: historyHttpCorrelation.error,
95
+ }),
96
+ error: historyHttpCorrelation.error,
97
+ ...correlation,
98
+ });
99
+ return;
100
+ }
101
+ }
102
+ if (!sessionId) {
103
+ sendJson(400, Protocol.conversationErrorEnvelope({
104
+ status: 'invalid_request', reason: 'missing_session_id',
105
+ }));
106
+ return;
107
+ }
108
+ if (!_deps.SESSION_ID_RE.test(sessionId)) {
109
+ sendJson(400, Protocol.conversationErrorEnvelope({
110
+ status: 'invalid_request', reason: 'invalid_session_id',
111
+ }));
112
+ return;
113
+ }
114
+
115
+ const beforeCursor = String(url.searchParams.get('before') || '').trim();
116
+ const afterCursor = String(url.searchParams.get('afterCursor') || '').trim();
117
+ const rangeCursor = String(url.searchParams.get('rangeCursor') || '').trim();
118
+ const cursorInputs = [beforeCursor, afterCursor, rangeCursor].filter(Boolean);
119
+ if (cursorInputs.length > 1) {
120
+ sendJson(400, Protocol.conversationErrorEnvelope({
121
+ status: 'invalid_cursor', reason: 'multiple_cursor_parameters',
122
+ }));
123
+ return;
124
+ }
125
+ const cursorToken = cursorInputs[0] || '';
126
+ const cursorDirection = beforeCursor ? 'older' : (afterCursor ? 'newer' : 'range');
127
+ const paginationMode = url.searchParams.get('mode') === 'turns' ? 'turns' : 'messages';
128
+ let offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10) || 0);
129
+ let exactRange = false;
130
+ let limit = Math.max(1, Math.min(
131
+ Cursor.MAX_PAGE_SIZE,
132
+ parseInt(url.searchParams.get('limit') || '200', 10) || 200,
133
+ ));
134
+ const compactConversation = paginationMode === 'turns'
135
+ && (url.searchParams.get('compact') === 'conversation'
136
+ || url.searchParams.get('conversationCompact') === '1');
137
+ const requestedContract = Number(url.searchParams.get('conversationContract'));
138
+ const conversationContract = requestedContract === 4 ? 4
139
+ : (requestedContract === 3 && process.env.CTM_CONVERSATION_V3 !== '0' ? 3 : 2);
140
+
141
+ // A contract-4 client never receives the contract-2 pending envelope: it gets the typed v4 503.
142
+ const sendReaderUnavailable = () => {
143
+ Protocol.logConversationServeRefusal(sessionId, 'canonical_reader_unavailable');
144
+ if (conversationContract === 4) {
145
+ sendJson(503, {
146
+ contractVersion: 4, status: 'error', transient: true, retryAfterMs: 750, reason: 'canonical_reader_unavailable',
147
+ }, { 'Retry-After': '1', 'Cache-Control': 'no-store' });
148
+ return;
149
+ }
150
+ sendJson(200, Protocol.pendingConversationEnvelope({ reason: 'canonical_reader_unavailable' }), { 'Retry-After': '1' });
151
+ };
152
+
153
+ _deps._focusConversationRecovery(sessionId);
154
+ const resolution = _deps._resolveCanonicalRowSources(sessionId);
155
+ const canonicalSessionId = String(resolution?.canonicalSessionId || sessionId).trim();
156
+ const rowSources = Array.isArray(resolution?.sources) && resolution.sources.length > 0
157
+ ? resolution.sources
158
+ : [{ id: canonicalSessionId, reason: 'canonical-fallback', rank: 1 }];
159
+ // D-3 (WP-2): a foreground viewer must never trade a real page for a fabricated pending
160
+ // envelope just because an auxiliary read (exclusions) or one row source's HWM failed.
161
+ // CTM_CONV_FAILSOFT=0 restores today's fail-hard-to-pending semantics byte-for-byte.
162
+ const failSoftEnabled = _deps._convFailSoftEnabled();
163
+ let exclusionRows;
164
+ let exclusionsDegraded = false;
165
+ try {
166
+ exclusionRows = await _deps._loadSessionMessageExclusionsOffThread(sessionId);
167
+ } catch {
168
+ if (!failSoftEnabled) {
169
+ sendReaderUnavailable();
170
+ return;
171
+ }
172
+ exclusionRows = _deps._peekSessionMessageExclusionsMemo(sessionId) || [];
173
+ exclusionsDegraded = true;
174
+ Protocol.logConversationServeRefusal(sessionId, 'exclusions_degraded');
175
+ }
176
+
177
+ let selectedRowSource = null;
178
+ let canonicalSourceKnown = false;
179
+ let hwmAttempted = 0;
180
+ let hwmFailed = 0;
181
+ for (const rowSource of rowSources) {
182
+ const rowSourceId = String(rowSource?.id || '').trim();
183
+ if (!rowSourceId) continue;
184
+ hwmAttempted += 1;
185
+ let rowHwm;
186
+ try {
187
+ rowHwm = await _deps._sessionMessageRowsHwmOffThread(rowSourceId, sessionId, {
188
+ preferRichProjection: true,
189
+ breaker: _deps._sessionMessagesForegroundBreaker,
190
+ });
191
+ } catch {
192
+ if (!failSoftEnabled) {
193
+ sendReaderUnavailable();
194
+ return;
195
+ }
196
+ hwmFailed += 1;
197
+ Protocol.logConversationServeRefusal(sessionId, 'row_hwm_degraded');
198
+ continue;
199
+ }
200
+ const freshness = _deps._sessionRowsFreshnessForSource(sessionId, rowSourceId, {
201
+ knownRowCount: Number(rowHwm?.rowCount || 0),
202
+ });
203
+ const sourceKnown = rowSource?.reason !== 'fallback'
204
+ || Number(freshness?.expectedSize || 0) > 0
205
+ || Number(rowHwm?.rowCount || 0) > 0;
206
+ canonicalSourceKnown = canonicalSourceKnown || sourceKnown;
207
+ if (!sourceKnown) continue;
208
+ const isCanonical = rowSource?.isCanonical === true || rowSourceId === canonicalSessionId;
209
+ if (!isCanonical) continue;
210
+ if (!(Number(rowHwm?.rowCount) > 0) && freshness?.fresh !== true) continue;
211
+ selectedRowSource = { rowSource, rowSourceId, rowHwm, freshness };
212
+ break;
213
+ }
214
+ // Only when EVERY attempted source failed does this degrade to the same pending envelope
215
+ // the old unconditional-return-on-first-failure code produced.
216
+ if (!selectedRowSource && failSoftEnabled && hwmAttempted > 0 && hwmFailed === hwmAttempted) {
217
+ sendReaderUnavailable();
218
+ return;
219
+ }
220
+
221
+ if (conversationContract === 4 && !selectedRowSource) {
222
+ // Same targeted import the v2/v3 tail triggers, so a source that has no rows yet gets some.
223
+ _deps._kickUiRowMissImport(sessionId, rowSources, canonicalSourceKnown);
224
+ sendJson(503, {
225
+ contractVersion: 4, status: 'error', transient: true, retryAfterMs: 750,
226
+ reason: 'no_canonical_source',
227
+ }, { 'Retry-After': '1', 'Cache-Control': 'no-store' });
228
+ return;
229
+ }
230
+
231
+ if (selectedRowSource) {
232
+ const { rowSourceId, rowHwm, freshness } = selectedRowSource;
233
+ if (freshness?.fresh !== true) {
234
+ const recoverySourceIds = [...new Set(rowSources
235
+ .map((source) => String(source?.id || '').trim())
236
+ .filter(Boolean))].slice(0, 16);
237
+ _deps._queueConversationCatchUpIfNeeded(
238
+ sessionId,
239
+ freshness,
240
+ 'canonical-row-page-stale',
241
+ { sourceIds: recoverySourceIds },
242
+ );
243
+ }
244
+ if (conversationContract === 4) {
245
+ const param = (name) => String(url.searchParams.get(name) || '').trim() || undefined;
246
+ let sessionGenerating = false;
247
+ try {
248
+ sessionGenerating = ['busy', 'running', 'working'].includes(_deps._standupStatusText(_deps.getSession(sessionId)));
249
+ } catch {
250
+ /* expected: an unloaded/unknown session is simply not generating */
251
+ }
252
+ const v4Payload = {
253
+ dbPath: _deps.dbModule.getDbPath(),
254
+ sessionId: rowSourceId,
255
+ before: param('before'),
256
+ after: param('after'),
257
+ around: param('around'),
258
+ entries: param('entries'),
259
+ limitTurns: Number(url.searchParams.get('limitTurns')) || undefined,
260
+ maxBytes: Number(url.searchParams.get('maxBytes')) || undefined,
261
+ outline: url.searchParams.get('outline') === '1',
262
+ resident: param('resident'),
263
+ sessionGenerating,
264
+ freshness: { fresh: freshness?.fresh === true },
265
+ };
266
+ let result = await _deps._buildConversationPageV4OffThread(v4Payload);
267
+ if (result?.error?.code === 'turn_index_missing') {
268
+ // The read-only worker cannot rebuild a missing index: heal on the writer DB, retry once.
269
+ try {
270
+ const healStartedAt = Date.now();
271
+ require('../conversation-turn-index').ensureTurnIndex(_deps.dbModule.getDb(), rowSourceId);
272
+ console.warn(`[conversation-v4] main-thread turn-index heal session=${rowSourceId} ms=${Date.now() - healStartedAt}`);
273
+ result = await _deps._buildConversationPageV4OffThread(v4Payload);
274
+ } catch (healError) {
275
+ result = { error: { code: 'turn_index_missing', message: String(healError?.message || healError).slice(0, 300) } };
276
+ }
277
+ }
278
+ if (Buffer.isBuffer(result)) {
279
+ const status = String(result[_deps._RESPONSE_CONTRACT_STATUS] || 'ready');
280
+ const statusCode = status === 'range-invalidated' ? 409 : (status === 'invalid_request' ? 400 : 200);
281
+ sendBuffer(statusCode, result, {
282
+ 'Cache-Control': 'no-store',
283
+ ...(status === 'syncing' ? { 'Retry-After': '1' } : {}),
284
+ });
285
+ return;
286
+ }
287
+ sendJson(503, {
288
+ contractVersion: 4, status: 'error', transient: true, retryAfterMs: 750,
289
+ reason: String(result?.error?.code || 'canonical_reader_unavailable'),
290
+ }, { 'Retry-After': '1', 'Cache-Control': 'no-store' });
291
+ return;
292
+ }
293
+ if (conversationContract === 3) {
294
+ const result = await _deps._buildSessionMessagesPageResponseFromRowsOffThread({
295
+ contractVersion: 3,
296
+ dbPath: _deps.dbModule.getDbPath(),
297
+ sessionId,
298
+ sessionImagesDir: _deps.dbModule.SESSION_IMAGES_DIR,
299
+ rowSourceId,
300
+ exclusionRows,
301
+ filterCodexSyntheticBase: _deps._sessionHasCodexMessageSource(sessionId),
302
+ mode: paginationMode,
303
+ direction: cursorDirection,
304
+ cursor: cursorToken || null,
305
+ limit,
306
+ maxSourceRows: 200,
307
+ compactConversation,
308
+ syncing: freshness?.fresh !== true,
309
+ telemetryReason: 'canonical-row-page-v3',
310
+ });
311
+ if (Buffer.isBuffer(result)) {
312
+ const status = String(result[_deps._RESPONSE_CONTRACT_STATUS] || 'error');
313
+ require('../conversation-range-metrics').recordAuthoritativePage(
314
+ {
315
+ ...(result[_deps._RESPONSE_CONTRACT_RANGE_METRICS] || { sessionId: rowSourceId, status }),
316
+ ...(result[_deps._RESPONSE_HISTORY_TIMING] || {}),
317
+ requestId: Number(historyHttpCorrelation?.fields?.historyRequestId) || 0,
318
+ },
319
+ );
320
+ const statusCode = status === 'range-invalidated' ? 409 : (status === 'error' ? 503 : 200);
321
+ const headers = { 'Cache-Control': 'no-store' };
322
+ if (status === 'upgrading' || status === 'syncing' || status === 'error') headers['Retry-After'] = '1';
323
+ sendBuffer(statusCode, result, headers);
324
+ return;
325
+ }
326
+ Protocol.logConversationServeRefusal(sessionId, 'canonical_reader_unavailable');
327
+ sendJson(503, Protocol.pendingConversationEnvelope({
328
+ reason: 'canonical_reader_unavailable',
329
+ }), { 'Retry-After': '1' });
330
+ return;
331
+ }
332
+ const generation = Math.max(0, Number(rowHwm?.projectionEpoch) || 0);
333
+ const identity = Protocol.canonicalTimelineIdentity(canonicalSessionId, generation);
334
+ if (cursorToken) {
335
+ const validation = Cursor.validateConversationPageCursor(cursorToken, {
336
+ sessionId,
337
+ identity,
338
+ generation,
339
+ mode: paginationMode,
340
+ direction: cursorDirection,
341
+ // Message counts cannot validate turn coordinates. Exact upper-bound
342
+ // validation is repeated by the worker against the requested domain.
343
+ maxOffset: paginationMode === 'messages'
344
+ ? Math.max(0, Number(rowHwm?.rowCount) || 0)
345
+ : undefined,
346
+ allowLegacy: true,
347
+ });
348
+ if (!validation.ok) {
349
+ if (validation.error === 'stale_cursor') {
350
+ sendJson(409, Protocol.staleCursorEnvelope({
351
+ identity,
352
+ generation,
353
+ reason: validation.field || 'timeline',
354
+ }));
355
+ } else {
356
+ sendJson(400, Protocol.conversationErrorEnvelope({
357
+ status: 'invalid_cursor', reason: validation.field || 'invalid_cursor',
358
+ }));
359
+ }
360
+ return;
361
+ }
362
+ offset = validation.cursor.offset;
363
+ if (validation.cursor.version === Cursor.CURSOR_VERSION) {
364
+ limit = validation.cursor.size;
365
+ exactRange = true;
366
+ }
367
+ }
368
+ const observedAt = new Date().toISOString();
369
+ const settled = freshness?.fresh === true;
370
+ const extra = {
371
+ settled,
372
+ catchingUp: !settled,
373
+ cacheFresh: settled,
374
+ readiness: {
375
+ reason: settled ? 'complete' : 'catching_up',
376
+ rowCount: Number(rowHwm.rowCount),
377
+ lagBytes: Math.max(0, Number(freshness?.lagBytes || 0)),
378
+ },
379
+ total_messages: Number(rowHwm.rowCount),
380
+ rowHwm,
381
+ rowSourceId,
382
+ requestedId: sessionId,
383
+ canonicalSessionId,
384
+ observedAt,
385
+ skipStreamTail: true,
386
+ // D-4-server additive field (WP-2 item 6): the page still served, but an auxiliary
387
+ // read degraded to last-known-good — never fabricated rows, just a badge hook for
388
+ // the client (WP-1 D-4). Omitted entirely (not `false`) on the healthy path so the
389
+ // shape is byte-identical to before this WP for the common case.
390
+ ...(exclusionsDegraded ? { exclusionsDegraded: true } : {}),
391
+ };
392
+ const result = await _deps._buildSessionMessagesPageResponseFromRowsOffThread({
393
+ dbPath: _deps.dbModule.getDbPath(),
394
+ sessionId,
395
+ sessionImagesDir: _deps.dbModule.SESSION_IMAGES_DIR,
396
+ rowSourceId,
397
+ streamEvents: [],
398
+ skipStreamTail: true,
399
+ // Store selection follows the exact HWM snapshot above. Freshness controls
400
+ // settled/catching-up truth, not which store owns that snapshot: choosing v2
401
+ // after a rich HWM was published creates a split 6-row count / 4-row payload.
402
+ // The worker still requires active Wall-E cutover + v2 coverage + exact count.
403
+ preferRichProjection: true,
404
+ expectedRowCount: Number(rowHwm.rowCount),
405
+ exclusionRows,
406
+ filterCodexSyntheticBase: _deps._sessionHasCodexMessageSource(sessionId),
407
+ extra,
408
+ publicIdentity: identity,
409
+ cursorContext: {
410
+ sessionId,
411
+ canonicalSessionId,
412
+ identity,
413
+ generation,
414
+ },
415
+ offset,
416
+ limit,
417
+ exactRange,
418
+ allowAuthoritativeEmpty: settled && Number(rowHwm.rowCount) === 0,
419
+ mode: paginationMode,
420
+ compactConversation,
421
+ telemetryReason: 'canonical-row-page',
422
+ });
423
+ if (result === 'empty') selectedRowSource = null;
424
+ if (result?.staleCursor === true) {
425
+ sendJson(409, Protocol.staleCursorEnvelope({
426
+ identity,
427
+ generation,
428
+ reason: result.field || 'offset',
429
+ ...Protocol.resumeHistoryObservationFields(result),
430
+ }));
431
+ return;
432
+ }
433
+ if (Buffer.isBuffer(result)) {
434
+ sendBuffer(200, result);
435
+ return;
436
+ }
437
+ if (result !== 'empty') {
438
+ Protocol.logConversationServeRefusal(sessionId, 'canonical_reader_unavailable');
439
+ sendJson(200, Protocol.pendingConversationEnvelope({
440
+ reason: 'canonical_reader_unavailable',
441
+ }), { 'Retry-After': '1' });
442
+ return;
443
+ }
444
+ }
445
+
446
+ if (cursorToken) {
447
+ sendJson(409, Protocol.staleCursorEnvelope({ reason: 'canonical_rows_not_ready' }));
448
+ return;
449
+ }
450
+
451
+ const sourceIds = _deps._kickUiRowMissImport(sessionId, rowSources, canonicalSourceKnown);
452
+ const _finalPendingReason = sourceIds.length > 0 ? 'canonical_rows_not_ready' : 'canonical_source_unavailable';
453
+ Protocol.logConversationServeRefusal(sessionId, _finalPendingReason);
454
+ sendJson(200, Protocol.pendingConversationEnvelope({
455
+ source: 'row_import_pending',
456
+ reason: _finalPendingReason,
457
+ }), { 'Retry-After': '1' });
458
+ }
459
+
460
+ async function _computePromptMessagesFromFreshestSource(sessionId) {
461
+ // The Queue Builder only needs the USER-PROMPT list, not the full conversation
462
+ // timeline. Building the full conversation projection on every prompt fetch
463
+ // does a SQLite read + JSON.parse of the entire cached
464
+ // conversation + transcript-store read + a merge/exclusions pass over the whole
465
+ // message array and was observed blocking the event loop up to 10s during a
466
+ // Codex "running" burst. The row-level message index
467
+ // already holds user prompts as a bounded, indexed query and is kept fresh by
468
+ // the same importer that powers Conversation/Review. So serve from the index
469
+ // FIRST + the live stream tail. An empty index degrades to that bounded tail
470
+ // while a background import converges; it never reconstructs history in-request.
471
+ // Phase 1: the heavy part (SELECT all user rows + sort + exclusions + sha1-per-prompt
472
+ // summary) runs on the read-pool worker (read-only connection), off the main loop. The 3
473
+ // small INPUTS (sourceIds/exclusions/codex-source) are resolved on the worker too by default
474
+ // (CTM_PROMPT_INDEX_RESOLVE_IN_WORKER) — that cold per-session resolution, stacked across a
475
+ // restore storm, is the apiSessionPrompts freeze. When the flag is off we resolve them here.
476
+ let indexSourceIds = _deps.CTM_PROMPT_INDEX_RESOLVE_IN_WORKER ? null : _deps._messageIndexPromptSourceIds(sessionId);
477
+ let indexExclusionRows = _deps.CTM_PROMPT_INDEX_RESOLVE_IN_WORKER
478
+ ? null
479
+ : await _deps._loadSessionMessageExclusionsOffThread(sessionId);
480
+ const indexResult = await _deps._computeUserPromptIndexOffThread(
481
+ sessionId,
482
+ _deps.CTM_PROMPT_INDEX_RESOLVE_IN_WORKER ? null : {
483
+ sourceIds: indexSourceIds,
484
+ exclusionRows: indexExclusionRows,
485
+ filterCodexSynthetic: _deps._sessionHasCodexMessageSource(sessionId),
486
+ }
487
+ );
488
+ // The worker echoes the resolved sourceIds back (main still needs them below for the
489
+ // stream-tail merge + deferred placeholder). On a breaker short-circuit / fallback the worker
490
+ // didn't resolve → [] (the deferred list is empty and sourceIds is only a downstream hint there).
491
+ if (indexSourceIds == null) {
492
+ indexSourceIds = Array.isArray(indexResult._resolvedSourceIds) ? indexResult._resolvedSourceIds : [];
493
+ }
494
+ if (indexExclusionRows == null) {
495
+ indexExclusionRows = Array.isArray(indexResult._resolvedExclusionRows)
496
+ ? indexResult._resolvedExclusionRows
497
+ : [];
498
+ }
499
+ const indexMessages = indexResult.messages || [];
500
+ if (indexResult.capped) {
501
+ console.warn(`[prompt-index] sid=${String(sessionId).slice(0, 8)} hit the per-session row ceiling (rowCount=${indexResult.rowCount}) — prompt nav shows the most-recent prompts only (defensive cap, not a data loss).`);
502
+ }
503
+ const indexRowsFreshness = _deps._promptRowsFreshness(sessionId, indexSourceIds || []);
504
+
505
+ if (indexResult._deferred) {
506
+ // Restore storm: the off-thread prompt-index compute was deferred to avoid a main-thread
507
+ // freeze and there's nothing cached to serve. Return an empty, explicitly-deferred result
508
+ // instead of running the heavy timeline projection (multi-MB JSON.parse) on the loop here.
509
+ // Not cached as final (see _loadPromptMessagesFromFreshestSource) → the next poll re-fetches.
510
+ return {
511
+ source: 'deferred',
512
+ promptMessages: [],
513
+ responsePrompts: [],
514
+ promptFingerprint: '',
515
+ lastPromptPreview: '',
516
+ userPromptCount: 0,
517
+ sourceIds: indexSourceIds || [],
518
+ streamTailAdded: 0,
519
+ cacheDetails: {},
520
+ cacheAvailable: false,
521
+ cacheFresh: false,
522
+ indexPromptCount: 0,
523
+ cachePromptCount: 0,
524
+ _deferred: true,
525
+ };
526
+ }
527
+
528
+ if (indexMessages.length > 0) {
529
+ if (indexRowsFreshness.fresh !== true) {
530
+ // Fire-and-forget freshness nudge: the index is stale, so schedule the background worker
531
+ // catch-up that makes the NEXT poll fresh. We serve THIS response from the index we already
532
+ // have — so this nudge must not block the response. Previously it ran inline on the request
533
+ // path and did synchronous cold fs (`_resolveAllSessionFiles`) + a throwaway
534
+ // durable transcript-store read whose result we discard, which is the
535
+ // low-CPU / high-latency half of the apiSessionPrompts freeze (I/O-wait, up to 3.4s). Defer it
536
+ // to a later tick, off the response's critical path, and probe it so [perf-lag] attributes its
537
+ // residual cost by name instead of as `apiSessionPrompts (last completed)`.
538
+ setImmediate(() => {
539
+ _deps._perfTracker.runSyncProbed('prompts:stale-catchup-schedule', () => {
540
+ _deps._resolveAllSessionFilesOffThread(sessionId, '', { origin: 'prompt-nudge:stale-index' }).then((filePaths) => {
541
+ if (filePaths.length > 0) {
542
+ return _deps._catchUpTranscriptStoreForSession(sessionId, filePaths, {
543
+ reason: 'prompt-index-source-stale',
544
+ scheduleOnly: true,
545
+ maxBytesPerFile: _deps.LARGE_SESSION_TAIL_READ_BYTES,
546
+ });
547
+ }
548
+ return null;
549
+ }).catch(() => {});
550
+ }, { context: () => ({ sid: String(sessionId).slice(0, 8) }) });
551
+ });
552
+ }
553
+ // Post-await sync chunk — the perf-tracker tag set by the parent
554
+ // apiSessionPrompts wrap was cleared at the await above, so without
555
+ // this probe these N-message merge/filter/summary passes log as
556
+ // active op:(unknown). Named here so [perf-lag] and the topOps trail
557
+ // in /api/diagnostics/event-loop can attribute the block.
558
+ return _deps._perfTracker.runSyncProbed(
559
+ 'prompts:index-merge-respond',
560
+ () => {
561
+ // Index hit — the common, hot path. Apply the live stream tail (recent prompts
562
+ // not yet flushed to the index) without touching the conversation cache.
563
+ const durableUserTailKeys = new Set(
564
+ Array.isArray(indexResult.userPromptTailIdentityKeys)
565
+ ? indexResult.userPromptTailIdentityKeys
566
+ : [],
567
+ );
568
+ const merge = _deps._mergeSessionMessagesWithStreamTail(sessionId, indexMessages, {
569
+ allowEmptyBase: true,
570
+ limit: 200,
571
+ eventFilter: (event) => {
572
+ const message = _deps.streamEventToMessage(event);
573
+ if (!message || String(message.role || '').trim() !== 'user') return false;
574
+ return !durableUserTailKeys.has(_deps.promptIdentityKey(message));
575
+ },
576
+ });
577
+ const mergedTailAdded = Math.max(0, (merge.messages || []).length - indexMessages.length);
578
+ const sourceIds = Array.from(new Set([
579
+ ...(indexSourceIds || []),
580
+ ...((merge.identity && merge.identity.conversationSourceIds) || []),
581
+ ]));
582
+ let withTail;
583
+ let sourceSummary;
584
+ let responsePrompts;
585
+ if (mergedTailAdded === 0) {
586
+ // No live prompt beyond the index — reuse the worker's filtered list + summary as-is
587
+ // (zero O(N) work on the main thread). This is the hot path for an active session.
588
+ withTail = indexMessages;
589
+ sourceSummary = { ...indexResult.summary, sourceIds };
590
+ responsePrompts = Array.isArray(indexResult.responsePrompts)
591
+ ? indexResult.responsePrompts
592
+ : [];
593
+ } else {
594
+ // Live prompts pending in the stream tail — fold them in on main (bounded: tail<=200).
595
+ withTail = (_deps._applySessionMessageExclusionsFromRows(
596
+ sessionId,
597
+ merge.messages,
598
+ indexExclusionRows,
599
+ { codexSynthetic: true },
600
+ ) || [])
601
+ .filter(m => String(m?.role || '').trim() === 'user');
602
+ sourceSummary = _deps._promptSummaryFromMessages(withTail, sourceIds);
603
+ responsePrompts = sourceSummary.responsePrompts;
604
+ }
605
+ const streamTailAdded = Math.max(0, withTail.length - indexMessages.length);
606
+ // The read-pool summary already counted filtered user prompts. Reuse it
607
+ // instead of reducing the full prompt array again on the main loop.
608
+ const indexPromptCount = Number(indexResult.summary?.userPromptCount || 0);
609
+ const indexFresh = indexRowsFreshness.fresh === true;
610
+ return {
611
+ source: streamTailAdded > 0
612
+ ? _deps._sourceWithStreamTail(indexFresh ? 'message-index' : 'message-index-stale', streamTailAdded)
613
+ : (indexFresh ? 'message-index' : 'message-index-stale'),
614
+ promptMessages: withTail,
615
+ responsePrompts,
616
+ promptFingerprint: sourceSummary.promptFingerprint,
617
+ lastPromptPreview: sourceSummary.lastPromptPreview,
618
+ userPromptCount: sourceSummary.userPromptCount,
619
+ sourceIds: sourceSummary.sourceIds,
620
+ streamTailAdded,
621
+ cacheDetails: {
622
+ cacheAvailable: true,
623
+ cacheFresh: indexFresh,
624
+ staleSourceIds: indexRowsFreshness.staleSourceIds || [],
625
+ missingSourceIds: indexRowsFreshness.presentSourceIds?.length ? [] : (indexRowsFreshness.sourceIds || []),
626
+ },
627
+ cacheAvailable: true,
628
+ cacheFresh: indexFresh,
629
+ freshness: indexRowsFreshness.state || (indexFresh ? 'fresh' : 'catching_up'),
630
+ indexPromptCount,
631
+ cachePromptCount: 0,
632
+ };
633
+ },
634
+ {
635
+ context: () => ({
636
+ sid: String(sessionId).slice(0, 8),
637
+ indexMessages: indexMessages.length,
638
+ }),
639
+ }
640
+ );
641
+ }
642
+
643
+ // Index empty: keep the request bounded to the in-memory stream tail and nudge
644
+ // the importer asynchronously. This is intentionally incomplete for one poll;
645
+ // synchronous discovery or full-history reconstruction would stall every tab.
646
+ setImmediate(() => {
647
+ _deps._resolveAllSessionFilesOffThread(sessionId, '', { origin: 'prompt-nudge:empty-index' }).then((filePaths) => {
648
+ if (filePaths.length === 0) return null;
649
+ return _deps._catchUpTranscriptStoreForSession(sessionId, filePaths, {
650
+ reason: 'prompt-index-empty',
651
+ scheduleOnly: true,
652
+ maxBytesPerFile: _deps.LARGE_SESSION_TAIL_READ_BYTES,
653
+ });
654
+ }).catch(() => {});
655
+ });
656
+ return _deps._perfTracker.runSyncProbed(
657
+ 'prompts:stream-tail-degraded-respond',
658
+ () => {
659
+ const merge = _deps._mergeSessionMessagesWithStreamTail(sessionId, [], {
660
+ allowEmptyBase: true,
661
+ limit: 200,
662
+ eventFilter: (event) => String(_deps.streamEventToMessage(event)?.role || '').trim() === 'user',
663
+ });
664
+ const tailMessages = _deps._applySessionMessageExclusionsFromRows(
665
+ sessionId,
666
+ merge.messages || [],
667
+ indexExclusionRows,
668
+ { codexSynthetic: true },
669
+ ).filter((message) => String(message?.role || '').trim() === 'user');
670
+ const sourceIds = Array.from(new Set([
671
+ ...(indexSourceIds || []),
672
+ ...((merge.identity && merge.identity.conversationSourceIds) || []),
673
+ ]));
674
+ const sourceSummary = _deps._promptSummaryFromMessages(tailMessages, sourceIds);
675
+ const streamTailAdded = tailMessages.length;
676
+
677
+ return {
678
+ source: streamTailAdded > 0 ? 'stream-tail-degraded' : 'deferred',
679
+ promptMessages: tailMessages,
680
+ responsePrompts: sourceSummary.responsePrompts,
681
+ promptFingerprint: sourceSummary.promptFingerprint,
682
+ lastPromptPreview: sourceSummary.lastPromptPreview,
683
+ userPromptCount: sourceSummary.userPromptCount,
684
+ sourceIds: sourceSummary.sourceIds,
685
+ streamTailAdded,
686
+ cacheDetails: { cacheAvailable: false, cacheFresh: false, sourceIds },
687
+ cacheAvailable: false,
688
+ cacheFresh: false,
689
+ indexPromptCount: 0,
690
+ cachePromptCount: 0,
691
+ _deferred: streamTailAdded === 0,
692
+ };
693
+ },
694
+ {
695
+ context: () => ({
696
+ sid: String(sessionId).slice(0, 8),
697
+ streamTailOnly: true,
698
+ }),
699
+ }
700
+ );
701
+ }
702
+
703
+ async function _apiRecentSessionsLegacy(req, res, url, options = {}) {
704
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
705
+ const forceRescan = url.searchParams.get('rescan') === '1';
706
+ // WP-3 (D-10): main's cheap registry snapshot for the DB-only projection below — the same input
707
+ // the read-pool op carries, so the legacy and published projections stamp identically. Deliberately
708
+ // read HERE, never in apiRecentSessions (tests/r4-9-recent-sessions-caps.test.js:144 pins that
709
+ // handler body to a pure published-buffer lookup).
710
+ const machineTags = _deps.remoteMachineCatalog.machineTagInputs();
711
+
712
+ // First access after (a cold) boot: seed the in-memory cache from the persisted snapshot so this
713
+ // request serves the last good list immediately instead of an empty one. It's marked stale, so
714
+ // the SWR path below still kicks an off-thread rebuild to refresh it.
715
+ if (!_deps._recentSessionsPersistLoaded) {
716
+ _deps._recentSessionsPersistLoaded = true;
717
+ if (!_deps._recentSessionsCache) _deps._loadPersistedRecentSessionsCache();
718
+ }
719
+
720
+ const now = Date.now();
721
+ let recentSessions;
722
+ let recentSessionsStale = false;
723
+ if (_deps._recentSessionsCache && (now - _deps._recentSessionsCacheTime) < _deps._RECENT_SESSIONS_CACHE_TTL && !forceRescan) {
724
+ recentSessions = _deps._recentSessionsCache;
725
+ } else if (!forceRescan) {
726
+ recentSessionsStale = true;
727
+ if (_deps._recentSessionsCache && _deps._recentSessionsCache.length > 0) {
728
+ // Stale-while-revalidate: hand back the last good value now (no multi-second
729
+ // getAllSessionsData scan on the request path) and refresh in the background.
730
+ recentSessions = _deps._recentSessionsCache;
731
+ _deps._scheduleRecentSessionsRebuild();
732
+ } else if (_deps._inRestoreStorm()) {
733
+ // Restore storm + cold cache: do NOT run the getAllSessionsData build on the request
734
+ // (main) thread — that's the freeze we're eliminating. Serve an empty list now and
735
+ // kick the off-thread rebuild; the UI re-polls and fills within a couple seconds.
736
+ recentSessions = [];
737
+ _deps._scheduleRecentSessionsRebuild();
738
+ } else {
739
+ // Cold/empty cache outside the restore-storm window. The synchronous getAllSessionsData
740
+ // build is ~700ms warm but 30s+ on cold Dropbox; running it on the request path froze the
741
+ // event loop (measured 31s), which both truncated this response ("No sessions found") AND
742
+ // tripped the restart watchdog — turning a slow boot into a freeze→restart→cold loop. NEVER
743
+ // build on the request path: serve whatever we have (stale or empty) and FORCE an off-thread
744
+ // rebuild (bypassing the breaker, since an empty cache must keep trying). The UI re-polls and
745
+ // the list fills once the read-pool finishes — off the main thread, no freeze.
746
+ recentSessions = (Array.isArray(_deps._recentSessionsCache) && _deps._recentSessionsCache.length > 0)
747
+ ? _deps._recentSessionsCache
748
+ : [];
749
+ _deps._scheduleRecentSessionsRebuild({ force: true });
750
+ }
751
+ } else {
752
+ recentSessionsStale = true;
753
+ recentSessions = Array.isArray(_deps._recentSessionsCache) ? _deps._recentSessionsCache : [];
754
+ _deps._scheduleRecentSessionsFullRescan(limit);
755
+ }
756
+
757
+ // R4-9: serve the cached serialized body when every stamped input is unchanged (see the cache
758
+ // definition for the key contract). Placed AFTER the SWR branch above so its rebuild-scheduling
759
+ // side effects still fire on every poll.
760
+ const _serializeCacheOn = _deps.CTM_RECENT_SERIALIZE_CACHE_ENABLED && !forceRescan && !options.catalogDegraded;
761
+ const _serializeKey = _serializeCacheOn
762
+ ? `${limit}|${_deps._recentSessionsCacheTime}|${_deps._recentSessionRowsMapCache.at}|${_deps._starredIdsCacheAt}|${recentSessionsStale ? 1 : 0}`
763
+ : '';
764
+ if (_serializeCacheOn && _deps._recentSessionsSerializedCache
765
+ && _deps._recentSessionsSerializedCache.key === _serializeKey
766
+ && (now - _deps._recentSessionsSerializedCache.at) < _deps._RECENT_SERIALIZE_CACHE_TTL_MS) {
767
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Recent-Cache': 'hit' });
768
+ res.end(_deps._recentSessionsSerializedCache.buffer);
769
+ return;
770
+ }
771
+
772
+ // Work on a shallow copy so title/mapping enrichment does not mutate the cache.
773
+ let result = recentSessions.map(s => ({ ...s }));
774
+
775
+ // Merge titles, stars, and CTM -> agent mappings from the canonical
776
+ // ctm_sessions + agent_sessions catalog.
777
+ let sessionsMap = {};
778
+ let allSessionRows = [];
779
+ // Reuse the by-id lookup cached from the SAME getAllSessionsData rows the list build/rebuild
780
+ // already fetched (avoids a second main-thread catalog query on every poll — the per-poll
781
+ // double-query that showed as the residual getAllSessionsData main runs during the storm).
782
+ const _mapFresh = _deps._recentSessionRowsMapCache.map && (now - _deps._recentSessionRowsMapCache.at) < _deps._RECENT_SESSIONS_CACHE_TTL;
783
+ if (result.length === 0) {
784
+ // Nothing to enrich (e.g. the storm served an empty list while the off-thread rebuild
785
+ // runs) — skip the catalog query entirely rather than build a lookup for zero sessions.
786
+ sessionsMap = _mapFresh ? _deps._recentSessionRowsMapCache.map : {};
787
+ allSessionRows = _mapFresh ? (_deps._recentSessionRowsMapCache.rows || []) : [];
788
+ } else if (_mapFresh) {
789
+ sessionsMap = _deps._recentSessionRowsMapCache.map;
790
+ allSessionRows = _deps._recentSessionRowsMapCache.rows || [];
791
+ } else {
792
+ // Enrichment is optional presentation data. Keep the last published lookup and refresh it
793
+ // off-thread; a cold exact catalog query here blocked the request loop for up to 31 seconds.
794
+ sessionsMap = _deps._recentSessionRowsMapCache.map || {};
795
+ allSessionRows = _deps._recentSessionRowsMapCache.rows || [];
796
+ _deps._scheduleRecentSessionsRebuild({ force: true });
797
+ }
798
+
799
+ // Filter: remove scanned sessions whose DB row was deleted by ghost prevention.
800
+ // Small/empty files without a DB entry are ghosts or abandoned stubs.
801
+ result = result.filter(s => {
802
+ if (_deps._isProviderChildAgentRow(s)) return false;
803
+ const dbRow = sessionsMap[s.sessionId];
804
+ if (_deps._isProviderChildSessionRow(dbRow)) return false;
805
+ if (dbRow) return true; // Has a DB entry → keep
806
+ // No DB entry. Check if this file was intentionally cleaned up (not just missing).
807
+ // A session without a DB row but WITH a JSONL file on disk was cleaned up by ghost prevention.
808
+ // Keep it only if it's a substantial session (>2KB, has user messages) — small/empty files
809
+ // without DB entries are ghosts or abandoned stubs.
810
+ if (s.fileSize > 2048 || s.userMsgCount > 0) return true;
811
+ return false;
812
+ });
813
+
814
+ const existingSessionIds = new Set(result.map(s => s.sessionId));
815
+ const dbOnlyRows = allSessionRows.filter((row) => !_deps._isProviderChildSessionRow(row)
816
+ && !existingSessionIds.has(row.id)
817
+ && !_deps.dbRowAgentSessionIds(row).some((id) => existingSessionIds.has(id)));
818
+ const fileStats = await _deps._mainTranscriptReads.statPaths(_deps._recentTranscriptPathsForRows(dbOnlyRows));
819
+ if (!fileStats || [...fileStats.values()].some((st) => st.state === 'error')) recentSessionsStale = true;
820
+
821
+ for (const s of result) {
822
+ const dbRow = sessionsMap[s.sessionId];
823
+ if (dbRow && dbRow.title) {
824
+ s.aiTitle = dbRow.title;
825
+ s.userRenamed = !!dbRow.user_renamed;
826
+ if (s.userRenamed) s.title = dbRow.title;
827
+ }
828
+ if (dbRow) {
829
+ s.userMsgCount = _deps.maxUserMsgCount(s.userMsgCount, dbRow.user_msg_count);
830
+ s.lastMessageAt = dbRow.last_message_at || s.lastMessageAt || '';
831
+ s.lastActivityAt = s.lastMessageAt || s.lastActivityAt || '';
832
+ s.titleSource = dbRow.title_source || '';
833
+ s.titleStatus = dbRow.title_status || '';
834
+ s.titleStatusReason = dbRow.title_status_reason || '';
835
+ s.titleUpdatedAt = dbRow.title_updated_at || '';
836
+ }
837
+ // Carry starred flag from sessions table
838
+ if (dbRow) s.starred = !!dbRow.starred;
839
+ // Carry dual-ID mapping so frontend can resolve in both directions
840
+ if (dbRow) {
841
+ const agentSessions = _deps._apiAgentSessionsFromDbRow(dbRow, { useFileStats: false });
842
+ if (agentSessions.length > 0) s.agentSessions = agentSessions;
843
+ if (dbRow.agent_session_id && dbRow.agent_session_id !== dbRow.id) {
844
+ s.agentSessionId = dbRow.agent_session_id;
845
+ }
846
+ const agentSessionIds = _deps.dbRowAgentSessionIds(dbRow);
847
+ if (agentSessionIds.length > 0) s.agentSessionIds = agentSessionIds;
848
+ if (dbRow.id !== s.sessionId) {
849
+ // s.sessionId is the JSONL file ID; dbRow.id is the CTM tab ID
850
+ s.provisionalId = dbRow.id;
851
+ }
852
+ }
853
+ // Surface the multi-tier title fallback (ported from upstream
854
+ // claude-code-history-viewer — gap-reports/viewer-2026-04-23.md items
855
+ // I + J). pickDisplayTitle blends ctm_sessions.title (auto or user-
856
+ // renamed), rename_name from a /rename command, and the file-scan's
857
+ // firstMessage + the new last_user_content / first_assistant_text
858
+ // columns so post-compact continuations inherit a useful title.
859
+ const rowForTitle = {
860
+ user_renamed: s.userRenamed ? 1 : 0,
861
+ title: s.aiTitle || s.title || '',
862
+ rename_name: dbRow ? dbRow.rename_name : '',
863
+ first_message: s.firstMessage || '',
864
+ last_user_content: dbRow ? dbRow.last_user_content : '',
865
+ first_assistant_text: dbRow ? dbRow.first_assistant_text : '',
866
+ };
867
+ s.displayTitle = _deps.dbModule.pickDisplayTitle(rowForTitle);
868
+ s.renameName = (dbRow && dbRow.rename_name) || '';
869
+ _deps.sessionEmpty.applyRecentSessionEmptyState(s, dbRow);
870
+ }
871
+
872
+ // Include DB-only sessions only when they are backed by canonical session
873
+ // identity, or explicitly preserved by the user. Auto-title/import caches are
874
+ // not session identity and must not float into the recent timeline.
875
+ for (const row of allSessionRows) {
876
+ if (_deps._isProviderChildSessionRow(row)) continue;
877
+ if (existingSessionIds.has(row.id)) continue;
878
+ if (_deps.dbRowAgentSessionIds(row).some(agentId => existingSessionIds.has(agentId))) continue;
879
+ const session = _deps._recentSessionFromDbRow(row, { fileStats, machineTags });
880
+ if (!_deps._shouldIncludeDbOnlyRecentSession(row, session, fileStats)) continue;
881
+ result.push(session);
882
+ existingSessionIds.add(row.id);
883
+ }
884
+
885
+ result = _deps._dedupeRecentSessionsByCanonicalId(result);
886
+
887
+ // Tag all sessions with current hostname
888
+ for (const s of result) s.hostname = _deps.HOSTNAME;
889
+
890
+ result.sort((a, b) => _deps._recentSessionActivityMs(b) - _deps._recentSessionActivityMs(a));
891
+
892
+ // Slice to limit, but ensure starred/pinned sessions are always included
893
+ let sliced = result.slice(0, limit);
894
+ try {
895
+ const slicedIds = new Set(sliced.map(s => s.sessionId));
896
+ // Starred sessions from sessions table
897
+ const starredRows = _deps._getCachedStarredSessionIds();
898
+ const starredSet = new Set();
899
+ for (const r of starredRows) {
900
+ starredSet.add(r.id);
901
+ if (r.agent_session_id) starredSet.add(r.agent_session_id);
902
+ }
903
+ // Legacy: also check ui_pinned_sessions setting
904
+ const pinnedIds = _deps._controlSnapshot.getSetting('ui_pinned_sessions', []);
905
+ if (Array.isArray(pinnedIds)) {
906
+ for (const pid of pinnedIds) starredSet.add(pid);
907
+ }
908
+ for (const s of result) {
909
+ if (!slicedIds.has(s.sessionId) && (starredSet.has(s.sessionId) || s.starred)) {
910
+ sliced.push(s);
911
+ slicedIds.add(s.sessionId);
912
+ }
913
+ }
914
+ } catch (e) { console.error('[recent-sessions] starred filter error:', e.message); }
915
+
916
+ // Include the last project where the user intentionally created a session
917
+ // so the frontend can auto-select it as the default project filter
918
+ let lastActiveProject = null;
919
+ try { lastActiveProject = _deps._controlSnapshot.getSetting('last_active_project', null); } catch (e) { console.error('[recent-sessions] getSetting error:', e.message); }
920
+
921
+ // R4-9: probed serialize (was an unprobed multi-hundred-KB stringify → `(unknown)` blocks)
922
+ // + populate the serialized-response cache for the next poll.
923
+ const _serializedBody = _deps._perfTracker.runSyncProbed('apiRecentSessions:serialize',
924
+ () => Buffer.from(JSON.stringify({
925
+ sessions: sliced,
926
+ lastActiveProject,
927
+ stale: recentSessionsStale || !!options.catalogDegraded,
928
+ catalogDegraded: !!options.catalogDegraded,
929
+ }), 'utf8'),
930
+ { context: () => ({ sessions: sliced.length }) });
931
+ if (_serializeCacheOn) {
932
+ _deps._recentSessionsSerializedCache = { key: _serializeKey, at: Date.now(), buffer: _serializedBody };
933
+ }
934
+ res.writeHead(200, { 'Content-Type': 'application/json' });
935
+ res.end(_serializedBody);
936
+ }
937
+
938
+ // WP-0 (S-3/S-4, fleet-session-lists-hub-search-2026-11-01.html §5 D-3, §6.1): the owner contract.
939
+ // `owner` is either absent (today's answer) or `self` (only rows THIS host owns). Anything else is
940
+ // a client bug, never a query — both routes answer 400 { error: 'owner_invalid' }.
941
+ const OWNER_FILTER_VALUES = Object.freeze(['', 'self']);
942
+
943
+ function normalizeOwnerFilter(value) {
944
+ const owner = String(value == null ? '' : value).trim();
945
+ return { ok: OWNER_FILTER_VALUES.includes(owner), owner };
946
+ }
947
+
948
+ // The owner a catalog row names — the same projection mapRecentSessionRow adds
949
+ // (lib/recent-catalog-query.js, S-1). A row that never went through that projection still answers
950
+ // from the raw columns, so the search path never depends on which of the two produced it.
951
+ function catalogRowOwner(row) {
952
+ if (!row) return '';
953
+ if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== null) {
954
+ return String(row.ownerInstanceId).trim();
955
+ }
956
+ return String(row.execution_host_instance_id || row.current_owner_instance_id || '').trim();
957
+ }
958
+
959
+ /**
960
+ * WP-0 (S-4): project `ownerInstanceId` onto every search result and, when `ownerLocalIds` is
961
+ * given, drop the rows it proves foreign — an UNKNOWN owner (`''`) is always kept, so a row that
962
+ * predates ownership stamping can never vanish from a box's answer.
963
+ * @param {object[]} rows fused search results
964
+ * @param {{ownerLocalIds?:Iterable<string>|null, ownerFor?:(row)=>string}} opts
965
+ */
966
+ function applyOwnerFilterToSearchResults(rows, { ownerLocalIds = null, ownerFor = null } = {}) {
967
+ const localIds = ownerLocalIds ? new Set(ownerLocalIds) : null;
968
+ const resolve = typeof ownerFor === 'function' ? ownerFor : () => '';
969
+ const projected = (Array.isArray(rows) ? rows : []).map((row) => ({
970
+ ...row,
971
+ ownerInstanceId: String(resolve(row) || '').trim(),
972
+ }));
973
+ return localIds
974
+ ? projected.filter((row) => !row.ownerInstanceId || localIds.has(row.ownerInstanceId))
975
+ : projected;
976
+ }
977
+
978
+ // The ids that count as THIS host's: the accessor the server's recent-catalog call site uses, so
979
+ // both surfaces filter against one truth. `_deps.getInstanceIdentity` wins when the seam injects it
980
+ // (that is how this module gets server state, and it is what lets a unit test stub it).
981
+ function _localOwnerInstanceIds() {
982
+ const accessor = typeof _deps?.getInstanceIdentity === 'function'
983
+ ? _deps.getInstanceIdentity
984
+ : require('../instance-identity-access').getInstanceIdentity;
985
+ try {
986
+ const lineage = accessor({ required: false })?.lineage;
987
+ // Fail OPEN: with no identity yet this box cannot tell its rows from copies, so it serves them
988
+ // unfiltered rather than as "owns nothing" (which would silently hide every owned hit). The
989
+ // hub's merge still drops rows owned by its own lineage (fleet-session-lists D-4).
990
+ return lineage && lineage.size ? new Set(lineage) : null;
991
+ } catch (e) {
992
+ // HANDLE: an identity that cannot be derived serves the search unfiltered, never fails it.
993
+ console.error('[search] owner filter: instance identity unavailable, serving unfiltered:', e?.message || e);
994
+ return null;
995
+ }
996
+ }
997
+
998
+ async function buildSessionSearchResponse(query, options = {}) {
999
+ const q = String(query || '').trim();
1000
+ const qLookup = _deps.SessionSearchUtils.normalizeSearchValue(q);
1001
+ const limit = Math.min(Math.max(parseInt(options.limit || '50', 10) || 50, 1), 100);
1002
+ if (!qLookup || qLookup.length < 2) return _deps._emptySessionSearchResponse(qLookup, limit);
1003
+
1004
+ const plan = _deps._sessionSearchPlan(qLookup, q);
1005
+ if (plan.terms.length === 0) return _deps._emptySessionSearchResponse(qLookup, limit);
1006
+
1007
+ try {
1008
+ _deps.requestConversationImport('session-search');
1009
+
1010
+ // Search is DB-only on the request path. Provider JSONL files are source
1011
+ // logs consumed by import/watch/repair jobs; this route must not discover or
1012
+ // parse them per keystroke.
1013
+ const ftsCorpusPromise = _deps._searchSessionFtsCorpusOffThread(qLookup, plan, limit);
1014
+ const catalog = await _deps._sessionCatalogForSearch();
1015
+ const ftsCorpus = await ftsCorpusPromise;
1016
+ const allSessionRows = catalog.rows;
1017
+ const _searchSessionsMap = catalog.map;
1018
+
1019
+ // Search uses the durable DB corpus. The request path should not parse the
1020
+ // JSONL filesystem on each keystroke; the background importer keeps these
1021
+ // rows fresh and is woken above when search runs.
1022
+ const { allSessions } = _deps._loadSearchCorpusSessions(
1023
+ allSessionRows, _searchSessionsMap, ftsCorpus.conversationRows,
1024
+ );
1025
+ const sessionMap = _deps._buildSearchSessionMap(allSessions);
1026
+
1027
+ const stripTags = (s) => s.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
1028
+
1029
+ // Build analysis lookup for enriching results
1030
+ const analysisMap = new Map();
1031
+ for (const a of ftsCorpus.analysisRows) {
1032
+ const rowId = a.ctm_session_id || a.session_id;
1033
+ if (!rowId) continue;
1034
+ analysisMap.set(rowId, a);
1035
+ const canonicalId = _deps._canonicalSearchSessionId(rowId, _searchSessionsMap);
1036
+ if (canonicalId && !analysisMap.has(canonicalId)) analysisMap.set(canonicalId, a);
1037
+ }
1038
+
1039
+ // --- Source 1: FTS5 on session_analyses ---
1040
+ const ftsResults = _deps._analysisFtsSearchRows(ftsCorpus.analysisFtsRows);
1041
+ const ftsScored = [];
1042
+ for (let i = 0; i < ftsResults.length; i++) {
1043
+ const r = ftsResults[i];
1044
+ const sourceSessionId = r.session_id;
1045
+ const dbRow = _searchSessionsMap ? _searchSessionsMap[sourceSessionId] : null;
1046
+ const sessionId = _deps._canonicalSearchSessionId(sourceSessionId, _searchSessionsMap);
1047
+ const a = analysisMap.get(sourceSessionId) || analysisMap.get(sessionId);
1048
+ if (!a) continue;
1049
+
1050
+ // Find best highlighted field for display
1051
+ let matchField = '';
1052
+ let matchSnippet = '';
1053
+ const highlights = [
1054
+ ['title', r.h_title], ['summary', r.h_summary], ['topics', r.h_topics],
1055
+ ['category', r.h_category], ['skills_used', r.h_skills],
1056
+ ['first_message', r.h_first_message], ['project', r.h_project],
1057
+ ];
1058
+ for (const [field, hl] of highlights) {
1059
+ if (hl && hl.includes('<mark>')) {
1060
+ matchField = field;
1061
+ // Extract snippet around first highlight
1062
+ const markIdx = hl.indexOf('<mark>');
1063
+ const start = Math.max(0, markIdx - 40);
1064
+ const end = Math.min(hl.length, markIdx + 100);
1065
+ matchSnippet = stripTags((start > 0 ? '...' : '') + hl.slice(start, end) + (end < hl.length ? '...' : ''));
1066
+ break;
1067
+ }
1068
+ }
1069
+ // session_analyses_fts is contentless, so SQLite can identify a matching
1070
+ // row without returning highlight text. Recover field attribution from
1071
+ // the durable analysis row using the exact terms FTS expanded.
1072
+ if (!matchField) {
1073
+ const expandedTerms = (r._fuzzyExpansions || []).flatMap(expansion => expansion?.variants || []);
1074
+ const matchTerms = [...new Set([...(plan.terms || []), ...expandedTerms])]
1075
+ .map(term => String(term || '').toLowerCase())
1076
+ .filter(Boolean);
1077
+ const analysisFields = [
1078
+ ['title', a.title], ['summary', a.summary], ['topics', a.topics],
1079
+ ['category', a.category], ['skills_used', a.skills_used],
1080
+ ['first_message', a.first_message], ['project', a.project],
1081
+ ];
1082
+ for (const [field, value] of analysisFields) {
1083
+ const text = String(value || '');
1084
+ const lower = text.toLowerCase();
1085
+ const term = matchTerms.find(candidate => lower.includes(candidate));
1086
+ if (!term) continue;
1087
+ const index = lower.indexOf(term);
1088
+ const start = Math.max(0, index - 40);
1089
+ const end = Math.min(text.length, index + term.length + 60);
1090
+ matchField = field;
1091
+ matchSnippet = stripTags((start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : ''));
1092
+ break;
1093
+ }
1094
+ }
1095
+
1096
+ const raw = sessionMap.get(sessionId) || sessionMap.get(sourceSessionId);
1097
+ const titleFields = _deps._searchResultTitleFields({ analysis: a, raw, dbRow, stripTags });
1098
+ const agentSessions = dbRow ? _deps._apiAgentSessionsFromDbRow(dbRow, { useFileStats: false }) : (raw?.agentSessions || []);
1099
+ const primaryAgent = dbRow ? _deps._primaryApiAgentSession(dbRow, agentSessions) : null;
1100
+ ftsScored.push({
1101
+ id: sessionId,
1102
+ sessionId,
1103
+ source: 'analysisFts',
1104
+ project: a.project || raw?.project || '',
1105
+ projectEntry: raw?.projectEntry || '',
1106
+ cwd: raw?.cwd || a.project || '',
1107
+ firstMessage: a.first_message || raw?.firstMessage || '',
1108
+ title: titleFields.title,
1109
+ aiTitle: titleFields.aiTitle,
1110
+ displayTitle: titleFields.displayTitle,
1111
+ analysisTitle: titleFields.analysisTitle,
1112
+ userRenamed: titleFields.userRenamed,
1113
+ isEmpty: false,
1114
+ userMsgCount: _deps.maxUserMsgCount(a.message_count, raw?.userMsgCount),
1115
+ modifiedAt: raw?.modifiedAt || a.session_modified_at || '',
1116
+ timestamp: raw?.modifiedAt || a.session_modified_at || '',
1117
+ version: raw?.version || '',
1118
+ gitBranch: raw?.gitBranch || '',
1119
+ fileSize: raw?.fileSize || 0,
1120
+ agent: dbRow?.provider || primaryAgent?.provider || raw?.agent || undefined,
1121
+ provisionalId: dbRow?.id && dbRow.id !== sourceSessionId ? dbRow.id : undefined,
1122
+ agentSessionIds: _deps.dbRowAgentSessionIds(dbRow),
1123
+ agentSessionId: primaryAgent && primaryAgent.agentSessionId !== dbRow?.id ? primaryAgent.agentSessionId : undefined,
1124
+ agentSessions,
1125
+ _matchField: matchField,
1126
+ _matchSnippet: matchSnippet,
1127
+ });
1128
+ }
1129
+
1130
+ // --- Source 3: FTS5 on session_messages ---
1131
+ const msgResults = _deps._dedupeSearchRows(ftsCorpus.messageRows, 'session_id');
1132
+ const msgBySession = _deps._groupMessageResults(msgResults, _searchSessionsMap, plan);
1133
+
1134
+ // --- Source 4: FTS5 on transcript_events ---
1135
+ // This covers provider append-tail rows, including Codex rollout events, from
1136
+ // SQLite. It must not fall back to scanning provider JSONL files.
1137
+ const eventScored = _deps._scoreTranscriptEventFtsRows(ftsCorpus.eventRows, plan, limit, _searchSessionsMap, stripTags);
1138
+
1139
+ // --- Source 6: direct id matches across CTM ids + agent ids ---
1140
+ const idScored = _deps.SessionSearchUtils.buildIdMatchResults(allSessions, qLookup);
1141
+
1142
+ // --- Source 5: exact session-id/URL references in prompts and transcripts ---
1143
+ const referenceScored = _deps._sessionReferenceSearch(
1144
+ q, limit, _searchSessionsMap, sessionMap, stripTags, ftsCorpus.referenceRows,
1145
+ );
1146
+
1147
+ // --- Metadata LIKE search for exact title/path recovery ---
1148
+ const likeScored = _deps._likeSearchMetadata(allSessions, plan.terms, stripTags, q, plan.phrases);
1149
+
1150
+ // --- Source 2: vector search over session analysis rows when cheaper
1151
+ // sources do not already provide enough candidates. Semantic search is
1152
+ // helpful for true "describe the thing I remember" queries, but local
1153
+ // embedding initialization can be expensive; keyword/id/message hits should
1154
+ // stay fast and local.
1155
+ const cheapCandidateCount = ftsScored.length + msgResults.length + eventScored.length + referenceScored.length + idScored.length + likeScored.length;
1156
+ const semanticResult = options.includeSemantic === false || !plan.descriptive || cheapCandidateCount >= _deps.CTM_SEARCH_SEMANTIC_MIN_LEXICAL_RESULTS
1157
+ ? { vectorScored: [], semantic: _deps._skippedSessionSearchSemanticStatus() }
1158
+ : await _deps._semanticSessionSearch(qLookup, limit, analysisMap, sessionMap, _searchSessionsMap, stripTags);
1159
+ const vectorScored = semanticResult.vectorScored;
1160
+
1161
+ // --- Merge all sources using RRF ---
1162
+ // WP-0 (S-4): with owner=self the owner filter runs BEFORE the final slice, so the fusion is
1163
+ // asked for every ranked candidate it has (the sources are limit-bounded upstream) and the
1164
+ // caller's limit is enforced on the FILTERED set — a foreign-owned hit can never starve an
1165
+ // owned one out of the list. Without owner the merge is called exactly as before.
1166
+ const ownerLocalIds = options.owner === 'self' ? _localOwnerInstanceIds() : null;
1167
+ const merged = _deps._mergeSearchSources(
1168
+ ftsScored, vectorScored, msgBySession, eventScored, referenceScored, idScored, likeScored,
1169
+ ownerLocalIds ? Number.MAX_SAFE_INTEGER : limit,
1170
+ analysisMap, sessionMap, stripTags, plan,
1171
+ );
1172
+ const ownerFor = (row) => {
1173
+ const id = String(row?.sessionId || row?.id || '');
1174
+ if (!id || !_searchSessionsMap || !Object.prototype.hasOwnProperty.call(_searchSessionsMap, id)) return '';
1175
+ return catalogRowOwner(_searchSessionsMap[id]);
1176
+ };
1177
+ const projected = applyOwnerFilterToSearchResults(merged, { ownerLocalIds, ownerFor });
1178
+ const results = ownerLocalIds ? projected.slice(0, limit) : projected;
1179
+
1180
+ return {
1181
+ results,
1182
+ total: results.length,
1183
+ mode: 'smart',
1184
+ query: { normalized: qLookup, terms: plan.terms, descriptive: plan.descriptive },
1185
+ sources: {
1186
+ analysisFts: ftsScored.length,
1187
+ messageFts: msgResults.length,
1188
+ transcriptEvents: eventScored.length,
1189
+ references: referenceScored.length,
1190
+ ids: idScored.length,
1191
+ metadata: likeScored.length,
1192
+ codex: 0,
1193
+ semantic: vectorScored.length,
1194
+ },
1195
+ semantic: semanticResult.semantic,
1196
+ degraded: ftsCorpus.degraded || { ftsCorpus: 'ok', message: null },
1197
+ ftsLagRows: Math.max(0, Number(ftsCorpus.ftsLagRows) || 0),
1198
+ ftsLagAlert: Number(ftsCorpus.ftsLagRows || 0)
1199
+ >= (Number(process.env.CTM_FTS_LAG_ALERT_ROWS) || 50_000),
1200
+ limit,
1201
+ };
1202
+ } catch (e) {
1203
+ console.error('[search] Error:', e.message);
1204
+ throw e;
1205
+ }
1206
+ }
1207
+
1208
+ function apiGenerateTitles(req, res) {
1209
+ let body = '';
1210
+ req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
1211
+ req.on('end', async () => {
1212
+ try {
1213
+ const { sessions } = JSON.parse(body);
1214
+ if (!Array.isArray(sessions) || sessions.length === 0) {
1215
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1216
+ res.end(JSON.stringify({ error: 'sessions array required' }));
1217
+ return;
1218
+ }
1219
+
1220
+ // Filter to sessions that don't already have titles (or user-renamed), skip CTM-internal sessions
1221
+ // Build title lookup from sessions table (single source of truth)
1222
+ const { rows: _allRows } = await _deps._readSessionCatalogOffThread('generate-titles');
1223
+ const existingTitles = {};
1224
+ for (const r of _allRows) {
1225
+ if (r.title) existingTitles[r.id] = { title: r.title, userRenamed: !!r.user_renamed, titleSource: r.title_source || '' };
1226
+ if (r.agent_session_id && r.title) existingTitles[r.agent_session_id] = { title: r.title, userRenamed: !!r.user_renamed, titleSource: r.title_source || '' };
1227
+ }
1228
+ const flattenExistingTitles = () => {
1229
+ const flatTitles = {};
1230
+ for (const [id, v] of Object.entries(existingTitles)) flatTitles[id] = v.title;
1231
+ return flatTitles;
1232
+ };
1233
+ // AC-3g (session-rename-mirror WP-3, §5 D-1): a row at the 'provider_title' tier (the
1234
+ // coding agent's own session name) is never overwritten by this tier-3 AI-title generator
1235
+ // — same "lower tier never overwrites a higher one" rule as the `userRenamed` check right
1236
+ // beside it. `existing.title` truthy already implies one of these two guards fired for
1237
+ // every populated row (existingTitles is only ever populated when r.title is set), so this
1238
+ // is belt-and-suspenders for the case a future caller narrows that population condition.
1239
+ const candidates = sessions.filter(s => {
1240
+ const existing = existingTitles[s.sessionId];
1241
+ if (existing && (existing.userRenamed || existing.titleSource === 'provider_title' || existing.title)) return false;
1242
+ return !!s.firstMessage;
1243
+ });
1244
+ // D-7 (WP-3): harness/provider boilerplate (<local-command-caveat>, <task-notification>,
1245
+ // "# AGENTS.md instructions for", a compaction summary) is not a human prompt — the same
1246
+ // predicate server/boot.js's background auto-title job gates its candidates on.
1247
+ const needTitles = [];
1248
+ for (const s of candidates) {
1249
+ if (_deps.isCtmInternalSession(s.firstMessage)) {
1250
+ _deps.persistTitleStatus(s.sessionId, 'skipped', 'ctm_internal');
1251
+ continue;
1252
+ }
1253
+ if (!_deps.isHumanPromptCandidate(s.provider, s.firstMessage)) {
1254
+ _deps.persistTitleStatus(s.sessionId, 'skipped', 'not_human_prompt');
1255
+ continue;
1256
+ }
1257
+ needTitles.push(s);
1258
+ }
1259
+
1260
+ if (needTitles.length === 0) {
1261
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1262
+ res.end(JSON.stringify({ titles: flattenExistingTitles(), generated: 0 }));
1263
+ return;
1264
+ }
1265
+
1266
+ // Batch up to 20 sessions at a time
1267
+ const batch = needTitles.slice(0, 20);
1268
+
1269
+ // Title generation is a background enhancement served entirely through
1270
+ // Wall-E's routing authority now (WP-2, D-1) — CTM no longer preflight-checks
1271
+ // provider credentials itself; a credential-less setup surfaces as a normal
1272
+ // callClaudeAsync failure below, classified by titleGenerationFailureInfo().
1273
+
1274
+ // Resume/continuation boilerplate is stripped by server/boot.js's cleanFirstMessage, shared
1275
+ // with the background auto-title job (its `This session is being continued…` line is gone:
1276
+ // isHumanPromptCandidate already rejects that compaction row).
1277
+ const prompt = `Generate short, descriptive titles (max 60 chars) for these Claude Code sessions.
1278
+ Each session has an ID and the user's first message. Create a title that summarizes what the user was working on.
1279
+
1280
+ IMPORTANT: Do NOT prefix titles with "Resume:", "Continue:", "Fix:", or similar action verbs. Just describe the topic/task directly.
1281
+ Good: "Shopping cart checkout flow redesign"
1282
+ Bad: "Resume: Update checkout page logic"
1283
+
1284
+ Sessions:
1285
+ ${batch.map((s, i) => `${i + 1}. [${s.sessionId}] ${_deps.cleanFirstMessage(s.firstMessage).slice(0, 300)}`).join('\n')}
1286
+
1287
+ Return ONLY a JSON array of objects with "id" and "title" fields. No markdown fences.`;
1288
+
1289
+ let result = '';
1290
+ try {
1291
+ result = await _deps.callClaudeAsync(prompt, {
1292
+ modelTier: 'fast',
1293
+ maxTokens: 1200,
1294
+ timeoutMs: 30000,
1295
+ task: 'session-title',
1296
+ });
1297
+ } catch (err) {
1298
+ const failure = _deps.titleGenerationFailureInfo(err);
1299
+ for (const s of batch) {
1300
+ _deps.persistTitleStatus(s.sessionId, 'failed', failure.reason);
1301
+ }
1302
+ console.warn('[titles] Skipping background title generation:', failure.detail || failure.reason);
1303
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1304
+ res.end(JSON.stringify({
1305
+ titles: flattenExistingTitles(),
1306
+ generated: 0,
1307
+ skipped: failure.reason,
1308
+ titleStatus: {
1309
+ status: 'failed',
1310
+ reason: failure.reason,
1311
+ provider: failure.provider,
1312
+ model: failure.model,
1313
+ userMessage: failure.userMessage,
1314
+ },
1315
+ }));
1316
+ return;
1317
+ }
1318
+ const match = result.match(/\[[\s\S]*\]/);
1319
+ if (match) {
1320
+ let titles = [];
1321
+ try {
1322
+ titles = JSON.parse(match[0]);
1323
+ } catch (err) {
1324
+ for (const s of batch) {
1325
+ _deps.persistTitleStatus(s.sessionId, 'failed', 'provider_response_invalid');
1326
+ }
1327
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1328
+ res.end(JSON.stringify({
1329
+ titles: flattenExistingTitles(),
1330
+ generated: 0,
1331
+ skipped: 'provider_response_invalid',
1332
+ titleStatus: {
1333
+ status: 'failed',
1334
+ reason: 'provider_response_invalid',
1335
+ userMessage: 'AI title generation returned an unreadable response. Existing session names are still available.',
1336
+ },
1337
+ }));
1338
+ return;
1339
+ }
1340
+ for (const t of titles) {
1341
+ if (t.id && t.title) {
1342
+ await _deps.dbModule.setSessionTitleNew(t.id, t.title, false, { source: 'ai_title', status: 'generated' });
1343
+ existingTitles[t.id] = { title: t.title, userRenamed: false };
1344
+ }
1345
+ }
1346
+ } else {
1347
+ for (const s of batch) {
1348
+ _deps.persistTitleStatus(s.sessionId, 'failed', 'provider_response_invalid');
1349
+ }
1350
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1351
+ res.end(JSON.stringify({
1352
+ titles: flattenExistingTitles(),
1353
+ generated: 0,
1354
+ skipped: 'provider_response_invalid',
1355
+ titleStatus: {
1356
+ status: 'failed',
1357
+ reason: 'provider_response_invalid',
1358
+ userMessage: 'AI title generation returned an unreadable response. Existing session names are still available.',
1359
+ },
1360
+ }));
1361
+ return;
1362
+ }
1363
+
1364
+ // Flatten to simple {id: title} for response
1365
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1366
+ res.end(JSON.stringify({ titles: flattenExistingTitles(), generated: batch.length }));
1367
+ } catch (err) {
1368
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1369
+ res.end(JSON.stringify({ error: err.message }));
1370
+ }
1371
+ });
1372
+ }
1373
+
1374
+ module.exports = {
1375
+ configure,
1376
+ apiSessionMessages,
1377
+ _computePromptMessagesFromFreshestSource,
1378
+ _apiRecentSessionsLegacy,
1379
+ applyOwnerFilterToSearchResults,
1380
+ buildSessionSearchResponse,
1381
+ catalogRowOwner,
1382
+ normalizeOwnerFilter,
1383
+ apiGenerateTitles,
1384
+ };