create-walle 0.9.22 → 0.9.23

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 (495) hide show
  1. package/README.md +22 -0
  2. package/package.json +1 -1
  3. package/template/CLAUDE.md +2 -2
  4. package/template/LICENSE +1 -1
  5. package/template/bin/ctm-dev-cleanup.js +24 -3
  6. package/template/bin/ctm-launch.sh +13 -0
  7. package/template/bin/dev.sh +156 -18
  8. package/template/bin/node-bin.sh +84 -0
  9. package/template/bin/pin-node.sh +51 -0
  10. package/template/claude-task-manager/api-prompts.js +1190 -182
  11. package/template/claude-task-manager/api-reviews.js +104 -13
  12. package/template/claude-task-manager/approval-agent.js +1360 -280
  13. package/template/claude-task-manager/bin/restart-ctm.sh +64 -23
  14. package/template/claude-task-manager/bin/storage-migration-supervisor.js +338 -0
  15. package/template/claude-task-manager/db.js +4071 -282
  16. package/template/claude-task-manager/docs/approval-ai-refinement.md +138 -0
  17. package/template/claude-task-manager/docs/approval-rescue-loop.md +74 -0
  18. package/template/claude-task-manager/docs/codex-operational-warning-health.md +107 -0
  19. package/template/claude-task-manager/docs/codex-resume-state-guard-design.md +17 -12
  20. package/template/claude-task-manager/docs/codex-terminal-render-controller-handoff.md +311 -0
  21. package/template/claude-task-manager/docs/coding-agent-hooks-architecture.md +418 -0
  22. package/template/claude-task-manager/docs/conversation-import-freshness.md +20 -0
  23. package/template/claude-task-manager/docs/google-workspace-auth-health.md +77 -0
  24. package/template/claude-task-manager/docs/image-paste-ux.md +10 -0
  25. package/template/claude-task-manager/docs/main-loop-offload-architecture.md +66 -0
  26. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +274 -519
  27. package/template/claude-task-manager/docs/mobile-live-streaming.md +27 -5
  28. package/template/claude-task-manager/docs/mobile-remote-submission-lifecycle.md +69 -0
  29. package/template/claude-task-manager/docs/phone-access-design.md +53 -15
  30. package/template/claude-task-manager/docs/phone-passkey-identity.md +122 -0
  31. package/template/claude-task-manager/docs/phone-setup.md +3 -0
  32. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +25 -1
  33. package/template/claude-task-manager/docs/remote-desktop-access-design.md +268 -0
  34. package/template/claude-task-manager/docs/restart-lifecycle-architecture.md +95 -0
  35. package/template/claude-task-manager/docs/runtime-work-control-plane.md +53 -0
  36. package/template/claude-task-manager/docs/session-interactive-wait-surfaces.md +38 -0
  37. package/template/claude-task-manager/docs/session-needs-you-dismissal.md +84 -0
  38. package/template/claude-task-manager/docs/session-render-state-management-design.md +91 -3
  39. package/template/claude-task-manager/docs/session-standup-command-center-design.md +25 -1
  40. package/template/claude-task-manager/docs/session-title-authority.md +32 -0
  41. package/template/claude-task-manager/docs/session-workspace-binding.md +33 -0
  42. package/template/claude-task-manager/docs/skill-intent-resolution-design.md +72 -0
  43. package/template/claude-task-manager/docs/walle-mcp-supervisor-health.md +86 -0
  44. package/template/claude-task-manager/docs/walle-relay-phone-access-design.md +24 -15
  45. package/template/claude-task-manager/docs/walle-session-history-hydration.md +114 -0
  46. package/template/claude-task-manager/docs/walle-session-input-queue.md +104 -0
  47. package/template/claude-task-manager/docs/walle-session-model-catalog.md +90 -0
  48. package/template/claude-task-manager/docs/walle-session-model-preferences.md +15 -6
  49. package/template/claude-task-manager/git-utils.js +751 -10
  50. package/template/claude-task-manager/lib/agent-capabilities.js +33 -0
  51. package/template/claude-task-manager/lib/agent-cli-cache.js +37 -7
  52. package/template/claude-task-manager/lib/agent-hooks-installer.js +26 -2
  53. package/template/claude-task-manager/lib/agent-presets.js +17 -1
  54. package/template/claude-task-manager/lib/all-sessions-query.js +108 -0
  55. package/template/claude-task-manager/lib/approval-ai-refinement.js +488 -0
  56. package/template/claude-task-manager/lib/approval-self-adapt.js +168 -0
  57. package/template/claude-task-manager/lib/async-semaphore.js +44 -0
  58. package/template/claude-task-manager/lib/auth-context.js +5 -0
  59. package/template/claude-task-manager/lib/auth-rate-limit.js +24 -1
  60. package/template/claude-task-manager/lib/auth-rules.js +26 -2
  61. package/template/claude-task-manager/lib/auto-approval-verifier.js +129 -16
  62. package/template/claude-task-manager/lib/background-llm.js +144 -17
  63. package/template/claude-task-manager/lib/branch-inventory.js +212 -0
  64. package/template/claude-task-manager/lib/claude-desktop-sessions.js +15 -3
  65. package/template/claude-task-manager/lib/coalesce-sync-frames.js +151 -0
  66. package/template/claude-task-manager/lib/codex-launch-health.js +762 -0
  67. package/template/claude-task-manager/lib/codex-transcript-pager.js +51 -0
  68. package/template/claude-task-manager/lib/codex-zst.js +124 -0
  69. package/template/claude-task-manager/lib/coding-agent-models.js +233 -30
  70. package/template/claude-task-manager/lib/connection-health.js +232 -0
  71. package/template/claude-task-manager/lib/conversation-blob-parser.js +42 -0
  72. package/template/claude-task-manager/lib/conversation-tail-merge.js +89 -26
  73. package/template/claude-task-manager/lib/ctm-session-context-api.js +39 -10
  74. package/template/claude-task-manager/lib/cursor-conversation-store.js +354 -0
  75. package/template/claude-task-manager/lib/db-owner-worker-client.js +315 -0
  76. package/template/claude-task-manager/lib/document-review.js +109 -5
  77. package/template/claude-task-manager/lib/escalation-review.js +152 -0
  78. package/template/claude-task-manager/lib/graceful-shutdown.js +159 -0
  79. package/template/claude-task-manager/lib/headless-term-service.js +678 -0
  80. package/template/claude-task-manager/lib/heavy-worker-fallback.js +38 -0
  81. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +542 -0
  82. package/template/claude-task-manager/lib/jsonl-range-reader.js +112 -0
  83. package/template/claude-task-manager/lib/main-db-census.js +216 -0
  84. package/template/claude-task-manager/lib/message-pagination.js +106 -4
  85. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +669 -28
  86. package/template/claude-task-manager/lib/mobile-auth-api.js +260 -7
  87. package/template/claude-task-manager/lib/mobile-auth-store.js +592 -10
  88. package/template/claude-task-manager/lib/mobile-notification-dispatcher.js +15 -0
  89. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +311 -0
  90. package/template/claude-task-manager/lib/model-overview-cache.js +141 -0
  91. package/template/claude-task-manager/lib/models-health-routing-notice.js +126 -0
  92. package/template/claude-task-manager/lib/node-pin-guard.js +93 -0
  93. package/template/claude-task-manager/lib/perf-tracker.js +242 -6
  94. package/template/claude-task-manager/lib/permission-match.js +76 -0
  95. package/template/claude-task-manager/lib/permission-sync.js +133 -20
  96. package/template/claude-task-manager/lib/process-title.js +35 -0
  97. package/template/claude-task-manager/lib/prompt-executions-query.js +25 -0
  98. package/template/claude-task-manager/lib/prompt-index-disk-cache.js +44 -0
  99. package/template/claude-task-manager/lib/prompt-intent.js +132 -0
  100. package/template/claude-task-manager/lib/provider-user-context.js +34 -0
  101. package/template/claude-task-manager/lib/read-pool-client.js +313 -0
  102. package/template/claude-task-manager/lib/readpool-breaker.js +31 -0
  103. package/template/claude-task-manager/lib/recent-sessions-breaker.js +12 -0
  104. package/template/claude-task-manager/lib/remote-feedback-client.js +72 -0
  105. package/template/claude-task-manager/lib/remote-relay-protocol.js +37 -4
  106. package/template/claude-task-manager/lib/remote-relay-store.js +159 -0
  107. package/template/claude-task-manager/lib/remote-submission-observer.js +278 -0
  108. package/template/claude-task-manager/lib/restart-guard.js +46 -5
  109. package/template/claude-task-manager/lib/restore-interruption-detector.js +439 -0
  110. package/template/claude-task-manager/lib/restore-policy.js +13 -0
  111. package/template/claude-task-manager/lib/restore-resume-batch.js +74 -0
  112. package/template/claude-task-manager/lib/restore-runtime.js +68 -0
  113. package/template/claude-task-manager/lib/restore-storm.js +34 -0
  114. package/template/claude-task-manager/lib/resume-cwd.js +36 -0
  115. package/template/claude-task-manager/lib/resume-preflight.js +313 -0
  116. package/template/claude-task-manager/lib/runtime-work-registry.js +444 -0
  117. package/template/claude-task-manager/lib/sanitize-openai-auth.js +31 -0
  118. package/template/claude-task-manager/lib/scheduler.js +21 -1
  119. package/template/claude-task-manager/lib/scrollback-snapshot-store.js +159 -0
  120. package/template/claude-task-manager/lib/serial-task-queue.js +64 -0
  121. package/template/claude-task-manager/lib/server-listeners.js +239 -0
  122. package/template/claude-task-manager/lib/session-capture.js +42 -7
  123. package/template/claude-task-manager/lib/session-content-backfill.js +131 -0
  124. package/template/claude-task-manager/lib/session-history.js +388 -43
  125. package/template/claude-task-manager/lib/session-host-manager.js +287 -0
  126. package/template/claude-task-manager/lib/session-image-refs.js +209 -0
  127. package/template/claude-task-manager/lib/session-jobs.js +399 -59
  128. package/template/claude-task-manager/lib/session-prompt-index.js +137 -0
  129. package/template/claude-task-manager/lib/session-restore.js +53 -0
  130. package/template/claude-task-manager/lib/session-standup.js +87 -10
  131. package/template/claude-task-manager/lib/session-state-bus.js +14 -0
  132. package/template/claude-task-manager/lib/session-stream.js +53 -12
  133. package/template/claude-task-manager/lib/session-timeline-summary.js +260 -0
  134. package/template/claude-task-manager/lib/session-token-usage.js +494 -0
  135. package/template/claude-task-manager/lib/session-workspace-binding.js +356 -0
  136. package/template/claude-task-manager/lib/setup-network-config.js +9 -0
  137. package/template/claude-task-manager/lib/size-cap.js +45 -0
  138. package/template/claude-task-manager/lib/size-cap.test.js +62 -0
  139. package/template/claude-task-manager/lib/skill-autocomplete.js +180 -1
  140. package/template/claude-task-manager/lib/skill-intent-resolver.js +304 -0
  141. package/template/claude-task-manager/lib/sqlite-driver.js +19 -3
  142. package/template/claude-task-manager/lib/standup-attention.js +7 -3
  143. package/template/claude-task-manager/lib/status-authority.js +39 -0
  144. package/template/claude-task-manager/lib/status-hooks.js +4 -0
  145. package/template/claude-task-manager/lib/storage-migration.js +235 -0
  146. package/template/claude-task-manager/lib/structured-capture.js +298 -0
  147. package/template/claude-task-manager/lib/sync-io-census.js +163 -0
  148. package/template/claude-task-manager/lib/tailscale-setup.js +6 -0
  149. package/template/claude-task-manager/lib/terminal-activity-evidence.js +33 -0
  150. package/template/claude-task-manager/lib/terminal-choice.js +364 -0
  151. package/template/claude-task-manager/lib/terminal-control-sanitize.js +17 -0
  152. package/template/claude-task-manager/lib/terminal-fingerprint.js +48 -0
  153. package/template/claude-task-manager/lib/terminal-output-flush.js +84 -0
  154. package/template/claude-task-manager/lib/timeline-order.js +122 -0
  155. package/template/claude-task-manager/lib/transcript-store.js +348 -43
  156. package/template/claude-task-manager/lib/transport-security.js +34 -1
  157. package/template/claude-task-manager/lib/wait-state.js +184 -0
  158. package/template/claude-task-manager/lib/walle-client.js +47 -5
  159. package/template/claude-task-manager/lib/walle-ctm-history.js +564 -4
  160. package/template/claude-task-manager/lib/walle-external-actions.js +135 -16
  161. package/template/claude-task-manager/lib/walle-history-hydration.js +46 -0
  162. package/template/claude-task-manager/lib/walle-native-health.js +403 -0
  163. package/template/claude-task-manager/lib/walle-repair.js +701 -0
  164. package/template/claude-task-manager/lib/walle-session-cache.js +109 -0
  165. package/template/claude-task-manager/lib/walle-session-context.js +57 -21
  166. package/template/claude-task-manager/lib/walle-session-model-catalog.js +34 -0
  167. package/template/claude-task-manager/lib/walle-supervisor.js +539 -63
  168. package/template/claude-task-manager/lib/walle-transcript.js +36 -0
  169. package/template/claude-task-manager/lib/worktree-active-sync.js +5 -4
  170. package/template/claude-task-manager/lib/worktree-cwd.js +32 -1
  171. package/template/claude-task-manager/package.json +1 -1
  172. package/template/claude-task-manager/prompt-harvest.js +89 -66
  173. package/template/claude-task-manager/providers/claude-code.js +51 -3
  174. package/template/claude-task-manager/providers/cursor.js +140 -45
  175. package/template/claude-task-manager/public/css/reviews.css +541 -61
  176. package/template/claude-task-manager/public/css/setup.css +178 -0
  177. package/template/claude-task-manager/public/css/walle-session.css +865 -10
  178. package/template/claude-task-manager/public/css/walle.css +9 -0
  179. package/template/claude-task-manager/public/designs/ai-providers-consolidation-v2.html +830 -0
  180. package/template/claude-task-manager/public/index.html +18043 -2080
  181. package/template/claude-task-manager/public/js/document-review-links.js +106 -1
  182. package/template/claude-task-manager/public/js/image-normalize.js +69 -36
  183. package/template/claude-task-manager/public/js/message-renderer.js +1252 -75
  184. package/template/claude-task-manager/public/js/prompts.js +66 -29
  185. package/template/claude-task-manager/public/js/reviews.js +871 -127
  186. package/template/claude-task-manager/public/js/session-activity-utils.js +11 -1
  187. package/template/claude-task-manager/public/js/session-search-utils.js +94 -10
  188. package/template/claude-task-manager/public/js/session-status-precedence.js +23 -5
  189. package/template/claude-task-manager/public/js/setup.js +1238 -181
  190. package/template/claude-task-manager/public/js/stream-view.js +671 -72
  191. package/template/claude-task-manager/public/js/terminal-reconciler.js +210 -0
  192. package/template/claude-task-manager/public/js/walle-session.js +2455 -158
  193. package/template/claude-task-manager/public/js/walle.js +141 -10
  194. package/template/claude-task-manager/public/m/app.css +2033 -164
  195. package/template/claude-task-manager/public/m/app.js +5633 -433
  196. package/template/claude-task-manager/public/m/claim.html +219 -19
  197. package/template/claude-task-manager/public/m/index.html +105 -16
  198. package/template/claude-task-manager/public/m/sw.js +3 -1
  199. package/template/claude-task-manager/public/manifest.json +2 -2
  200. package/template/claude-task-manager/public/prompts.html +30 -14
  201. package/template/claude-task-manager/queue-engine.js +507 -28
  202. package/template/claude-task-manager/scripts/repair-claude-session-images.js +27 -8
  203. package/template/claude-task-manager/server.js +13981 -2107
  204. package/template/claude-task-manager/session-integrity.js +156 -18
  205. package/template/claude-task-manager/session-search-ranking.js +1 -0
  206. package/template/claude-task-manager/session-utils.js +25 -5
  207. package/template/claude-task-manager/workers/approval-blocklist.js +96 -6
  208. package/template/claude-task-manager/workers/approval-widget-validator.js +14 -8
  209. package/template/claude-task-manager/workers/conversation-import-worker.js +11 -50
  210. package/template/claude-task-manager/workers/db-owner-worker.js +386 -0
  211. package/template/claude-task-manager/workers/harvest-worker.js +9 -55
  212. package/template/claude-task-manager/workers/headless-term-worker.js +9 -530
  213. package/template/claude-task-manager/workers/read-pool-worker.js +387 -0
  214. package/template/claude-task-manager/workers/scrollback-worker.js +11 -72
  215. package/template/claude-task-manager/workers/session-host-process.js +146 -0
  216. package/template/claude-task-manager/workers/session-integrity-worker.js +10 -54
  217. package/template/claude-task-manager/workers/state-detectors/base.js +18 -1
  218. package/template/claude-task-manager/workers/state-detectors/claude-code.js +182 -9
  219. package/template/claude-task-manager/workers/state-detectors/codex.js +150 -2
  220. package/template/claude-task-manager/workers/state-detectors/cursor.js +127 -0
  221. package/template/claude-task-manager/workers/state-detectors/gemini.js +21 -0
  222. package/template/claude-task-manager/workers/state-detectors/index.js +29 -0
  223. package/template/claude-task-manager/workers/state-detectors/opencode.js +103 -0
  224. package/template/docs/design/markdown-review-pane.md +206 -0
  225. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +54 -14
  226. package/template/docs/designs/2026-05-20-mobile-worktree-finish-command.md +27 -0
  227. package/template/docs/designs/2026-05-22-ai-configuration-consolidation.md +248 -0
  228. package/template/docs/designs/ai-configuration-consolidation-mock.html +812 -0
  229. package/template/docs/private-memory-and-pii-policy.md +69 -0
  230. package/template/package.json +2 -1
  231. package/template/scripts/check-private-data.js +201 -0
  232. package/template/shared/sqlite-owner-guard.js +30 -0
  233. package/template/shared/sqlite-owner-write-queue.js +225 -0
  234. package/template/shared/sqlite-storage-policy.js +111 -0
  235. package/template/shared/sqlite-write-lock.js +428 -0
  236. package/template/wall-e/agent-runners/claude-code.js +5 -0
  237. package/template/wall-e/agent.js +166 -22
  238. package/template/wall-e/api-walle.js +505 -69
  239. package/template/wall-e/auth/provider-flows.js +11 -1
  240. package/template/wall-e/bin/walle-mcp-stdio.js +341 -17
  241. package/template/wall-e/brain.js +1463 -136
  242. package/template/wall-e/chat/attachment-blocks.js +96 -0
  243. package/template/wall-e/chat/attachments.js +2 -1
  244. package/template/wall-e/chat/capability-resolver.js +7 -7
  245. package/template/wall-e/chat/context-messages.js +28 -0
  246. package/template/wall-e/chat/conversation-frame.js +630 -0
  247. package/template/wall-e/chat/provider-messages.js +125 -0
  248. package/template/wall-e/chat.js +926 -242
  249. package/template/wall-e/coding/acceptance-contract.js +170 -0
  250. package/template/wall-e/coding/acp-adapter.js +1 -1
  251. package/template/wall-e/coding/agent-catalog.js +3 -0
  252. package/template/wall-e/coding/artifact-store.js +93 -0
  253. package/template/wall-e/coding/capability-router.js +120 -0
  254. package/template/wall-e/coding/coding-run-controller.js +423 -0
  255. package/template/wall-e/coding/compaction-service.js +157 -12
  256. package/template/wall-e/coding/frontend-verification.js +258 -0
  257. package/template/wall-e/coding/lifecycle-hooks.js +75 -0
  258. package/template/wall-e/coding/local-preview-contract.js +157 -0
  259. package/template/wall-e/coding/permission-service.js +57 -13
  260. package/template/wall-e/coding/prompt-bundle.js +19 -1
  261. package/template/wall-e/coding/prompt-section-registry.js +227 -0
  262. package/template/wall-e/coding/provider-compat.js +15 -0
  263. package/template/wall-e/coding/runtime-events.js +224 -0
  264. package/template/wall-e/coding/runtime-mode.js +3 -0
  265. package/template/wall-e/coding/side-git-snapshot.js +160 -4
  266. package/template/wall-e/coding/snapshot-service.js +143 -1
  267. package/template/wall-e/coding/stream-processor.js +388 -34
  268. package/template/wall-e/coding/task-tool.js +141 -4
  269. package/template/wall-e/coding/tool-execution-controller.js +365 -0
  270. package/template/wall-e/coding/tool-registry.js +43 -5
  271. package/template/wall-e/coding/user-hooks.js +217 -0
  272. package/template/wall-e/coding-orchestrator.js +1224 -209
  273. package/template/wall-e/coding-prompts.js +20 -4
  274. package/template/wall-e/context/context-builder.js +15 -2
  275. package/template/wall-e/decision/confidence.js +1 -1
  276. package/template/wall-e/docs/coding-acceptance-contract.md +41 -0
  277. package/template/wall-e/docs/external-action-controller.md +26 -6
  278. package/template/wall-e/docs/telemetry-lifecycle.md +8 -2
  279. package/template/wall-e/embeddings.js +591 -53
  280. package/template/wall-e/external-action-controller.js +12 -0
  281. package/template/wall-e/http/auth.js +1 -0
  282. package/template/wall-e/http/chat-api.js +46 -11
  283. package/template/wall-e/http/model-admin.js +727 -56
  284. package/template/wall-e/lib/boot-profile.js +88 -0
  285. package/template/wall-e/lib/event-loop-monitor.js +93 -0
  286. package/template/wall-e/llm/anthropic.js +123 -5
  287. package/template/wall-e/llm/client.js +236 -67
  288. package/template/wall-e/llm/default-fallback.js +382 -0
  289. package/template/wall-e/llm/health.js +19 -0
  290. package/template/wall-e/llm/message-guard.js +78 -0
  291. package/template/wall-e/llm/model-catalog.js +252 -1
  292. package/template/wall-e/llm/openai.js +10 -3
  293. package/template/wall-e/llm/portkey-sync.js +489 -36
  294. package/template/wall-e/llm/provider-error.js +30 -2
  295. package/template/wall-e/llm/registry.js +5 -1
  296. package/template/wall-e/llm/request-compat.js +67 -0
  297. package/template/wall-e/loops/backfill.js +79 -23
  298. package/template/wall-e/loops/brain-optimize.js +67 -0
  299. package/template/wall-e/loops/ingest.js +25 -10
  300. package/template/wall-e/loops/question-digest.js +160 -0
  301. package/template/wall-e/loops/reflect.js +6 -4
  302. package/template/wall-e/loops/think.js +39 -12
  303. package/template/wall-e/mcp-server.js +318 -36
  304. package/template/wall-e/memory/ctm-context-client.js +52 -14
  305. package/template/wall-e/memory/ctm-operational-context.js +237 -0
  306. package/template/wall-e/memory/ctm-prompt-executions-client.js +128 -0
  307. package/template/wall-e/memory/ctm-session-context.js +111 -63
  308. package/template/wall-e/prompts/coding/deepseek.txt +3 -0
  309. package/template/wall-e/prompts/coding/gemini.txt +6 -0
  310. package/template/wall-e/prompts/coding/gpt.txt +6 -0
  311. package/template/wall-e/prompts/coding/local.txt +7 -0
  312. package/template/wall-e/runtime/decision-hooks.js +115 -0
  313. package/template/wall-e/runtime/devbox-gateway.js +82 -8
  314. package/template/wall-e/runtime/prompt-manifest.js +86 -0
  315. package/template/wall-e/runtime/tool-executor.js +269 -0
  316. package/template/wall-e/runtime/tool-result-envelope.js +138 -0
  317. package/template/wall-e/runtime/transcript-projection.js +60 -0
  318. package/template/wall-e/runtime/walle-runtime.js +224 -0
  319. package/template/wall-e/scripts/db-optimize/migrate.js +162 -0
  320. package/template/wall-e/scripts/db-optimize/recall-eval.js +117 -0
  321. package/template/wall-e/server.js +2 -0
  322. package/template/wall-e/session-files.js +9 -0
  323. package/template/wall-e/skills/_bundled/google-calendar/run.js +1 -1
  324. package/template/wall-e/skills/_bundled/gws-workspace/run.js +1 -1
  325. package/template/wall-e/skills/_bundled/slack-mentions/run.js +76 -6
  326. package/template/wall-e/skills/claude-code-reader.js +7 -3
  327. package/template/wall-e/skills/script-skill-runner.js +10 -0
  328. package/template/wall-e/skills/skill-planner.js +38 -0
  329. package/template/wall-e/tools/builtin-middleware.js +19 -9
  330. package/template/wall-e/tools/local-tools.js +1428 -16
  331. package/template/wall-e/tools/permission-checker.js +73 -5
  332. package/template/wall-e/tools/question-manager.js +117 -7
  333. package/template/wall-e/training/harvester.js +12 -28
  334. package/template/wall-e/training/replay.js +25 -80
  335. package/template/wall-e/eval/ab-test.js +0 -203
  336. package/template/wall-e/eval/agent-runner.js +0 -772
  337. package/template/wall-e/eval/agent-scorer.js +0 -461
  338. package/template/wall-e/eval/aggregator.js +0 -414
  339. package/template/wall-e/eval/allowed-test-commands.js +0 -34
  340. package/template/wall-e/eval/benchmark-generator.js +0 -113
  341. package/template/wall-e/eval/benchmarks/chat-eval.json +0 -1662
  342. package/template/wall-e/eval/benchmarks/chat.json +0 -82
  343. package/template/wall-e/eval/benchmarks/coding-agent-real.json +0 -1
  344. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -1581
  345. package/template/wall-e/eval/benchmarks/coding.json +0 -122
  346. package/template/wall-e/eval/benchmarks/memory-retrieval.json +0 -234
  347. package/template/wall-e/eval/benchmarks/reasoning.json +0 -82
  348. package/template/wall-e/eval/benchmarks/swebench-lite-30.json +0 -212
  349. package/template/wall-e/eval/benchmarks.js +0 -669
  350. package/template/wall-e/eval/cc-replay.js +0 -719
  351. package/template/wall-e/eval/chat-eval.js +0 -525
  352. package/template/wall-e/eval/check-keys.js +0 -15
  353. package/template/wall-e/eval/check-providers.js +0 -42
  354. package/template/wall-e/eval/codex-cli-baseline.js +0 -669
  355. package/template/wall-e/eval/coding-agent-real.js +0 -570
  356. package/template/wall-e/eval/context-compactor.js +0 -251
  357. package/template/wall-e/eval/debug-agent003.js +0 -68
  358. package/template/wall-e/eval/diagnostics.js +0 -216
  359. package/template/wall-e/eval/eval-orchestrator.js +0 -642
  360. package/template/wall-e/eval/evaluate.js +0 -202
  361. package/template/wall-e/eval/evaluator.js +0 -373
  362. package/template/wall-e/eval/exporter.js +0 -212
  363. package/template/wall-e/eval/fixtures/express-basic/package.json +0 -9
  364. package/template/wall-e/eval/fixtures/express-basic/server.js +0 -115
  365. package/template/wall-e/eval/fixtures/express-basic/test.js +0 -83
  366. package/template/wall-e/eval/fixtures/express-buggy/package.json +0 -9
  367. package/template/wall-e/eval/fixtures/express-buggy/server.js +0 -113
  368. package/template/wall-e/eval/fixtures/express-buggy/test.js +0 -83
  369. package/template/wall-e/eval/fixtures/express-buggy-items/package.json +0 -9
  370. package/template/wall-e/eval/fixtures/express-buggy-items/server.js +0 -112
  371. package/template/wall-e/eval/fixtures/express-buggy-items/test.js +0 -83
  372. package/template/wall-e/eval/fixtures/express-buggy-search/package.json +0 -9
  373. package/template/wall-e/eval/fixtures/express-buggy-search/server.js +0 -121
  374. package/template/wall-e/eval/fixtures/express-buggy-search/test.js +0 -83
  375. package/template/wall-e/eval/fixtures/express-rename-data/data.js +0 -34
  376. package/template/wall-e/eval/fixtures/express-rename-data/package.json +0 -9
  377. package/template/wall-e/eval/fixtures/express-rename-data/server.js +0 -97
  378. package/template/wall-e/eval/fixtures/express-rename-data/test.js +0 -88
  379. package/template/wall-e/eval/fixtures/express-xss/package.json +0 -12
  380. package/template/wall-e/eval/fixtures/express-xss/server.js +0 -90
  381. package/template/wall-e/eval/fixtures/express-xss/test.js +0 -67
  382. package/template/wall-e/eval/fixtures/express-xss/views/profile.ejs +0 -9
  383. package/template/wall-e/eval/fixtures/fullstack-app/config/default.js +0 -9
  384. package/template/wall-e/eval/fixtures/fullstack-app/config/test.js +0 -13
  385. package/template/wall-e/eval/fixtures/fullstack-app/package.json +0 -11
  386. package/template/wall-e/eval/fixtures/fullstack-app/public/css/style.css +0 -137
  387. package/template/wall-e/eval/fixtures/fullstack-app/public/index.html +0 -46
  388. package/template/wall-e/eval/fixtures/fullstack-app/public/js/app.js +0 -121
  389. package/template/wall-e/eval/fixtures/fullstack-app/public/js/auth.js +0 -71
  390. package/template/wall-e/eval/fixtures/fullstack-app/public/js/items.js +0 -80
  391. package/template/wall-e/eval/fixtures/fullstack-app/public/js/users.js +0 -46
  392. package/template/wall-e/eval/fixtures/fullstack-app/public/login.html +0 -45
  393. package/template/wall-e/eval/fixtures/fullstack-app/public/register.html +0 -38
  394. package/template/wall-e/eval/fixtures/fullstack-app/scripts/migrate.js +0 -23
  395. package/template/wall-e/eval/fixtures/fullstack-app/scripts/seed.js +0 -46
  396. package/template/wall-e/eval/fixtures/fullstack-app/server/db.js +0 -99
  397. package/template/wall-e/eval/fixtures/fullstack-app/server/index.js +0 -94
  398. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/auth.js +0 -19
  399. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/logger.js +0 -19
  400. package/template/wall-e/eval/fixtures/fullstack-app/server/router.js +0 -50
  401. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/auth.js +0 -69
  402. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/health.js +0 -23
  403. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/items.js +0 -88
  404. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/users.js +0 -75
  405. package/template/wall-e/eval/fixtures/fullstack-app/server/test.js +0 -198
  406. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/response.js +0 -34
  407. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/validate.js +0 -26
  408. package/template/wall-e/eval/fixtures/fullstack-app/server.js +0 -8
  409. package/template/wall-e/eval/fixtures/fullstack-app/test.js +0 -12
  410. package/template/wall-e/eval/fixtures/monorepo-basic/package.json +0 -8
  411. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/data.js +0 -58
  412. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/middleware.js +0 -46
  413. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/package.json +0 -8
  414. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/routes.js +0 -64
  415. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/server.js +0 -56
  416. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/test.js +0 -116
  417. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/commands.js +0 -61
  418. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/index.js +0 -62
  419. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/output.js +0 -43
  420. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/package.json +0 -11
  421. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/test.js +0 -44
  422. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/formatters.js +0 -43
  423. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/index.js +0 -12
  424. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/package.json +0 -5
  425. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/test.js +0 -55
  426. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/validators.js +0 -29
  427. package/template/wall-e/eval/fixtures/monorepo-basic/test.js +0 -46
  428. package/template/wall-e/eval/fixtures/node-cli/index.js +0 -78
  429. package/template/wall-e/eval/fixtures/node-cli/package.json +0 -10
  430. package/template/wall-e/eval/fixtures/node-cli/test.js +0 -57
  431. package/template/wall-e/eval/fixtures/node-typed/package.json +0 -8
  432. package/template/wall-e/eval/fixtures/node-typed/src/handlers.js +0 -31
  433. package/template/wall-e/eval/fixtures/node-typed/src/utils.js +0 -33
  434. package/template/wall-e/eval/fixtures/node-typed/test.js +0 -36
  435. package/template/wall-e/eval/fixtures/python-flask/app.py +0 -14
  436. package/template/wall-e/eval/fixtures/python-flask/requirements.txt +0 -2
  437. package/template/wall-e/eval/fixtures/python-flask/test_app.py +0 -25
  438. package/template/wall-e/eval/fixtures/wall-e-subset/brain.js +0 -105
  439. package/template/wall-e/eval/fixtures/wall-e-subset/eval/aggregator.js +0 -101
  440. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/chat.json +0 -20
  441. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/coding.json +0 -32
  442. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks.js +0 -64
  443. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/package.json +0 -6
  444. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/server.js +0 -31
  445. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/test.js +0 -18
  446. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/utils.js +0 -34
  447. package/template/wall-e/eval/fixtures/wall-e-subset/eval/runner.js +0 -104
  448. package/template/wall-e/eval/fixtures/wall-e-subset/eval/scorer.js +0 -73
  449. package/template/wall-e/eval/fixtures/wall-e-subset/eval/test.js +0 -134
  450. package/template/wall-e/eval/fixtures/wall-e-subset/llm/client.js +0 -99
  451. package/template/wall-e/eval/fixtures/wall-e-subset/llm/providers.js +0 -63
  452. package/template/wall-e/eval/fixtures/wall-e-subset/llm/test.js +0 -70
  453. package/template/wall-e/eval/fixtures/wall-e-subset/package.json +0 -10
  454. package/template/wall-e/eval/fixtures/wall-e-subset/test.js +0 -86
  455. package/template/wall-e/eval/harvester.js +0 -685
  456. package/template/wall-e/eval/head-to-head.js +0 -388
  457. package/template/wall-e/eval/humaneval-adapter.js +0 -321
  458. package/template/wall-e/eval/list-models.js +0 -31
  459. package/template/wall-e/eval/livecodebench-adapter.js +0 -291
  460. package/template/wall-e/eval/mail-integration.js +0 -443
  461. package/template/wall-e/eval/manifest.js +0 -186
  462. package/template/wall-e/eval/meta-harness/adapters/coding-agent.js +0 -57
  463. package/template/wall-e/eval/meta-harness/bootstrap-snapshot.js +0 -149
  464. package/template/wall-e/eval/meta-harness/candidate-store.js +0 -117
  465. package/template/wall-e/eval/meta-harness/cli.js +0 -86
  466. package/template/wall-e/eval/meta-harness/domain-spec.js +0 -154
  467. package/template/wall-e/eval/meta-harness/domains/coding-agent.domain.json +0 -84
  468. package/template/wall-e/eval/meta-harness/examples/env-bootstrap-candidate.js +0 -29
  469. package/template/wall-e/eval/meta-harness/experience-store.js +0 -174
  470. package/template/wall-e/eval/meta-harness/frontier.js +0 -96
  471. package/template/wall-e/eval/meta-harness/harness-interface.js +0 -90
  472. package/template/wall-e/eval/meta-harness/leakage-guard.js +0 -80
  473. package/template/wall-e/eval/meta-harness/optimizer.js +0 -207
  474. package/template/wall-e/eval/meta-harness/proposer-runner.js +0 -110
  475. package/template/wall-e/eval/meta-harness/reporting.js +0 -58
  476. package/template/wall-e/eval/meta-harness/telemetry.js +0 -27
  477. package/template/wall-e/eval/meta-harness/validation.js +0 -81
  478. package/template/wall-e/eval/promoter.js +0 -228
  479. package/template/wall-e/eval/provider-normalizer.js +0 -33
  480. package/template/wall-e/eval/replay.js +0 -395
  481. package/template/wall-e/eval/run-agent-benchmarks.js +0 -386
  482. package/template/wall-e/eval/run-codex-cli-baseline.js +0 -177
  483. package/template/wall-e/eval/run-coding-agent-real.js +0 -187
  484. package/template/wall-e/eval/run-eval.js +0 -435
  485. package/template/wall-e/eval/run-model-comparison.js +0 -142
  486. package/template/wall-e/eval/session-evaluator.js +0 -187
  487. package/template/wall-e/eval/session-miner.js +0 -207
  488. package/template/wall-e/eval/session-retrieval-benchmark.js +0 -150
  489. package/template/wall-e/eval/session-transcripts.js +0 -509
  490. package/template/wall-e/eval/shadow.js +0 -161
  491. package/template/wall-e/eval/swebench-adapter.js +0 -345
  492. package/template/wall-e/eval/swebench-docker.js +0 -192
  493. package/template/wall-e/eval/train.py +0 -320
  494. package/template/wall-e/eval/trainer.js +0 -232
  495. package/template/wall-e/eval/weekly-eval-loop.js +0 -241
@@ -5,28 +5,91 @@ const os = require('os');
5
5
  const db = require('./db');
6
6
  const queueEngine = require('./queue-engine');
7
7
  const harvest = require('./prompt-harvest');
8
- const permissionSync = require('./lib/permission-sync');
8
+ const approvalAgent = require('./approval-agent');
9
+ const escalationReview = require('./lib/escalation-review');
10
+ // permission-sync (Claude/Codex settings mirroring) intentionally removed —
11
+ // CTM permissions are a standalone global config in the CTM DB.
9
12
  const walleClient = require('./lib/walle-client');
10
13
  const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
11
14
  const skillAutocomplete = require('./lib/skill-autocomplete');
15
+ const skillIntentResolver = require('./lib/skill-intent-resolver');
12
16
  const resourceLinks = require('./lib/resource-links');
17
+ const storageMigration = require('./lib/storage-migration');
18
+ const { runSync } = require('./lib/perf-tracker');
13
19
  const {
14
20
  ingestJsonlFile,
15
21
  normalizeProvider: normalizeTranscriptProvider,
16
22
  sourceIdFromPath: transcriptSourceIdFromPath,
17
23
  } = require('./lib/transcript-store');
24
+ const { queryPromptExecutions } = require('./lib/prompt-executions-query');
25
+ const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
26
+ const structuredCapture = require('./lib/structured-capture');
18
27
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
19
28
 
20
29
  let dbMaintenanceRunner = null;
30
+ let imageSaveRunner = null;
21
31
  const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
22
32
  const TRANSCRIPT_IMPORT_MAX_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_MAX_BYTES || 10 * 1024 * 1024));
23
33
  const TRANSCRIPT_IMPORT_LARGE_FILE_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_LARGE_FILE_BYTES || 64 * 1024 * 1024));
24
34
  const CONVERSATION_IMPORT_RETRY_AFTER_MS = 30 * 1000;
35
+ const BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES = 2 * 1024 * 1024;
25
36
 
26
37
  function setDbMaintenanceRunner(fn) {
27
38
  dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
28
39
  }
29
40
 
41
+ function setImageSaveRunner(fn) {
42
+ imageSaveRunner = typeof fn === 'function' ? fn : null;
43
+ }
44
+
45
+ // deferRow=true returns as soon as the file is on disk (id:null), firing the
46
+ // images-table INSERT in the background — used by the terminal paste hot path,
47
+ // which forwards the file path/token to the provider and never needs the row id.
48
+ // Callers that need the real id (feedback attachments, prompt-editor annotation)
49
+ // leave deferRow falsy and get the awaited row.
50
+ async function saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow = false) {
51
+ if (imageSaveRunner) {
52
+ return await imageSaveRunner({ promptId, buffer, filename, mimeType, deferRow });
53
+ }
54
+ return await db.saveImageDecoupled(promptId, buffer, filename, mimeType, { deferRow });
55
+ }
56
+
57
+ // Measurement scaffolding for "image paste sometimes takes a long time". Logs one
58
+ // `[img-upload]` line per save (→ ctm.log) and returns the result with the internal
59
+ // `_timing` stripped so it isn't sent to the client. `roundTripMs` is the full
60
+ // saveImageViaOwner/saveImageFromFile call; `queueMs` ≈ roundTrip minus the
61
+ // worker-side hash/write/insert (i.e. db-owner queue wait + IPC/structured-clone of
62
+ // the buffer). writeMs/copyMs is the suspected Dropbox file-provider cost.
63
+ function _logImageTiming(label, meta, result) {
64
+ try {
65
+ const t = (result && result._timing) || {};
66
+ const workMs = (t.hashMs || 0) + (t.writeMs || 0) + (t.copyMs || 0) + (t.insertMs || 0);
67
+ const rt = meta.roundTripMs;
68
+ const queueMs = rt != null ? Math.max(0, Math.round(rt - workMs)) : null;
69
+ const bytes = meta.bytes != null ? meta.bytes : (t.bytes != null ? t.bytes : '?');
70
+ const writePart = ('writeMs' in t)
71
+ ? `writeMs=${t.writeMs}${t.wrote === false ? '(dedup)' : ''}`
72
+ : ('copyMs' in t ? `copyMs=${t.copyMs}` : 'writeMs=-');
73
+ // `path` (caller source, e.g. terminal/prompt/mobile) + `defer` (was the row INSERT
74
+ // deferred = the terminal fast path) disambiguate which upload route a slow line came
75
+ // from. defer=1 with insertMs=0 + tiny roundTrip is the terminal paste fast path;
76
+ // a slow line with defer=0 is a route that intentionally awaits the row (prompt
77
+ // editor / mobile / feedback).
78
+ const deferMark = (meta.defer != null) ? (meta.defer ? 1 : 0) : (t.deferred ? 1 : null);
79
+ console.log(
80
+ `[img-upload] ${label} bytes=${bytes}`
81
+ + (meta.source ? ` path=${meta.source}` : '')
82
+ + (deferMark != null ? ` defer=${deferMark}` : '')
83
+ + (meta.readMs != null ? ` readMs=${Math.round(meta.readMs)}` : '')
84
+ + (rt != null ? ` roundTripMs=${Math.round(rt)}` : '')
85
+ + (queueMs != null ? ` queueMs=${queueMs}` : '')
86
+ + ` hashMs=${t.hashMs != null ? t.hashMs : '-'} ${writePart} insertMs=${t.insertMs != null ? t.insertMs : '-'}`
87
+ );
88
+ } catch { /* never let logging break an upload */ }
89
+ if (result && result._timing) { const r = { ...result }; delete r._timing; return r; }
90
+ return result;
91
+ }
92
+
30
93
  // Embed a prompt (async, fire-and-forget)
31
94
  async function _embedPrompt(promptId, title, content) {
32
95
  try {
@@ -142,6 +205,7 @@ function handlePromptApi(req, res, url) {
142
205
 
143
206
  // --- Images ---
144
207
  if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
208
+ if (p === '/api/images/ingest-path' && m === 'POST') return handleIngestImagePath(req, res);
145
209
  if (p === '/api/mobile/attachments/upload' && m === 'POST') return handleUploadMobileAttachment(req, res, url);
146
210
  if (p === '/api/session/image-refs' && m === 'POST') return handleSessionImageRefs(req, res);
147
211
  if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
@@ -195,6 +259,10 @@ function handlePromptApi(req, res, url) {
195
259
  if (p === '/api/backups' && m === 'POST') return handleCreateBackup(req, res);
196
260
  if (p === '/api/backups/restore' && m === 'POST') return handleRestoreBackup(req, res);
197
261
  if (p.match(/^\/api\/backups\/[^/]+$/) && m === 'DELETE') return handleDeleteBackup(req, res, url);
262
+ if (p === '/api/storage/locations' && m === 'GET') return handleGetStorageLocations(req, res);
263
+ if (p === '/api/storage/backup-dirs' && m === 'PUT') return handlePutBackupDirs(req, res);
264
+ if (p === '/api/storage/migration/preview' && m === 'POST') return handlePreviewStorageMigration(req, res);
265
+ if (p === '/api/storage/migration/apply' && m === 'POST') return handleApplyStorageMigration(req, res);
198
266
 
199
267
  // --- Tool Permissions (Claude Code native) ---
200
268
  if (p === '/api/tool-permissions/scan' && m === 'POST') return handleScanToolUsage(req, res);
@@ -229,6 +297,10 @@ function handlePromptApi(req, res, url) {
229
297
  return handleResolveApprovalDecision(req, res, parseInt(p.split('/')[3]));
230
298
  }
231
299
 
300
+ // --- Escalation review (grouped "Needs Review" surface in the Permission page) ---
301
+ if (p === '/api/approval-escalations' && m === 'GET') return handleListEscalations(req, res);
302
+ if (p === '/api/approval-escalations/resolve' && m === 'POST') return handleResolveEscalations(req, res);
303
+
232
304
  // --- Dangerous-command blocklist (opt-in safety net) ---
233
305
  if (p === '/api/approval/blocklist' && m === 'GET') return handleGetBlocklist(req, res);
234
306
  if (p === '/api/approval/blocklist' && m === 'POST') return handleSetBlocklistEnabled(req, res);
@@ -248,7 +320,7 @@ function handlePromptApi(req, res, url) {
248
320
  if (draftMatch && m === 'PUT') return handleSaveQueueDraft(req, res, draftMatch[1]);
249
321
  if (draftMatch && m === 'DELETE') return handleDeleteQueueDraft(req, res, draftMatch[1]);
250
322
  const queueMatch = p.match(/^\/api\/queues\/([^/]+)$/);
251
- const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode)$/);
323
+ const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode|remove|reorder)$/);
252
324
  if (queueMatch && !queueActionMatch && m === 'GET') return handleGetQueue(req, res, queueMatch[1]);
253
325
  if (queueMatch && !queueActionMatch && m === 'DELETE') return handleDeleteQueue(req, res, queueMatch[1]);
254
326
  if (queueActionMatch && m === 'POST') return handleQueueAction(req, res, queueActionMatch[1], queueActionMatch[2]);
@@ -266,6 +338,7 @@ function handlePromptApi(req, res, url) {
266
338
  if (p === '/api/copilot/chat' && m === 'POST') return handleCopilotChat(req, res);
267
339
  if (p === '/api/prompt-quality' && m === 'GET') return handlePromptQuality(req, res, url);
268
340
  if (p === '/api/prompt-executions' && m === 'GET') return handleListExecutions(req, res, url);
341
+ if (p === '/api/prompt-executions/stats' && m === 'GET') return handlePromptExecutionStats(req, res);
269
342
  const execSessionMatch = p.match(/^\/api\/prompt-executions\/session\/([^/]+)$/);
270
343
  if (execSessionMatch && m === 'GET') return handleSessionExecutions(req, res, execSessionMatch[1]);
271
344
  const execOutcomeMatch = p.match(/^\/api\/prompt-executions\/(\d+)\/outcome$/);
@@ -284,6 +357,7 @@ function handlePromptApi(req, res, url) {
284
357
  if (p === '/api/prompts/hybrid-search' && m === 'GET') return handleHybridSearch(req, res, url);
285
358
  if (p === '/api/prompts/improve' && m === 'POST') return handleImprovePrompt(req, res);
286
359
  if (p === '/api/skills/autocomplete' && m === 'GET') return handleSkillAutocomplete(req, res, url);
360
+ if (p === '/api/skills/resolve-intent' && m === 'GET') return handleSkillResolveIntent(req, res, url);
287
361
  if (p === '/api/harvest/stats' && m === 'GET') return handleHarvestStats(req, res);
288
362
  if (p === '/api/prompts/pattern-suggestions' && m === 'GET') return handlePatternSuggestions(req, res);
289
363
 
@@ -647,12 +721,120 @@ async function handleUploadImage(req, res, url) {
647
721
  const promptId = parseInt(url.searchParams.get('prompt_id') || '0');
648
722
  const filename = url.searchParams.get('filename') || 'image.png';
649
723
  const mimeType = req.headers['content-type'] || 'image/png';
724
+ // defer_row=1: return as soon as the file is written (id:null), firing the DB
725
+ // INSERT in the background. The terminal paste path sets this so a paste isn't
726
+ // stuck behind the db-owner write queue.
727
+ const deferRow = url.searchParams.get('defer_row') === '1';
728
+ const _tRead0 = performance.now();
650
729
  const buffer = await readRawBody(req);
651
- const result = await db.saveImage(promptId, buffer, filename, mimeType);
652
- jsonResponse(res, 201, result);
730
+ const _tRead1 = performance.now();
731
+ const result = await saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow);
732
+ const safe = _logImageTiming('upload', {
733
+ bytes: buffer.length, readMs: _tRead1 - _tRead0, roundTripMs: performance.now() - _tRead1,
734
+ source: url.searchParams.get('source') || '', defer: deferRow,
735
+ }, result);
736
+ jsonResponse(res, 201, safe);
653
737
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
654
738
  }
655
739
 
740
+ const INGEST_IMAGE_EXT_MIME = {
741
+ '.png': 'image/png',
742
+ '.jpg': 'image/jpeg',
743
+ '.jpeg': 'image/jpeg',
744
+ '.gif': 'image/gif',
745
+ '.webp': 'image/webp',
746
+ '.heic': 'image/heic',
747
+ '.heif': 'image/heif',
748
+ '.bmp': 'image/bmp',
749
+ '.tif': 'image/tiff',
750
+ '.tiff': 'image/tiff',
751
+ };
752
+
753
+ // Decode a file:// URL or accept a plain absolute path. Returns '' if neither.
754
+ function _ingestPathFromInput(value) {
755
+ const raw = String(value || '').trim();
756
+ if (!raw) return '';
757
+ if (/^file:\/\//i.test(raw)) {
758
+ try { return path.resolve(decodeURIComponent(new URL(raw).pathname || '')); }
759
+ catch { return ''; }
760
+ }
761
+ if (path.isAbsolute(raw)) return path.resolve(raw);
762
+ return '';
763
+ }
764
+
765
+ // Only stabilize image references that live in user-controlled temp/home roots.
766
+ // This is the macOS screenshot/drag case (e.g. /var/folders/.../TemporaryItems/...png).
767
+ // Refuse arbitrary system paths so this localhost-only endpoint can't be coaxed
768
+ // into copying sensitive files into the served images dir.
769
+ //
770
+ // SECURITY: `candidate` MUST already be a realpath-resolved path (symlinks
771
+ // followed) and each root is realpath-resolved here, so a `.png` symlink living
772
+ // inside an allowed temp root but pointing at e.g. /etc/shadow cannot pass the
773
+ // containment check (its real target is outside the allowed roots). A purely
774
+ // string-based path.resolve() check would be bypassable by such a symlink.
775
+ function _ingestSourceAllowed(candidate) {
776
+ const realRoots = [];
777
+ const pushRoot = (p) => {
778
+ if (!p) return;
779
+ // Resolve each root through realpath too (e.g. macOS /tmp -> /private/tmp,
780
+ // /var -> /private/var) so the comparison is symlink-stable on both sides.
781
+ try { realRoots.push(fs.realpathSync(p)); } catch { /* root may not exist */ }
782
+ };
783
+ try { pushRoot(os.tmpdir()); } catch {}
784
+ for (const env of ['TMPDIR', 'HOME']) {
785
+ if (process.env[env]) pushRoot(process.env[env]);
786
+ }
787
+ // macOS per-user temp + screenshot scratch live under /var/folders and /private/var/folders.
788
+ for (const r of ['/var/folders', '/private/var/folders', '/tmp', '/private/tmp']) pushRoot(r);
789
+ return realRoots.some(root => {
790
+ const rel = path.relative(root, candidate);
791
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
792
+ });
793
+ }
794
+
795
+ // Ingest a local image FILE by path (not bytes) and return a stable, served copy.
796
+ // Used by the terminal image-reference paste path so CTM forwards a stable
797
+ // images-dir path to the provider instead of the ephemeral macOS temp path
798
+ // (which leaks the local fs and disappears once the screenshot scratch is reaped).
799
+ async function handleIngestImagePath(req, res) {
800
+ try {
801
+ const body = await readBody(req, 64 * 1024);
802
+ const resolved = _ingestPathFromInput(body && (body.path || body.url || body.reference));
803
+ if (!resolved) return jsonResponse(res, 400, { error: 'invalid_path' });
804
+ const ext = path.extname(resolved).toLowerCase();
805
+ const mimeType = INGEST_IMAGE_EXT_MIME[ext];
806
+ if (!mimeType) return jsonResponse(res, 400, { error: 'unsupported_image_type' });
807
+ // SECURITY: reject symlinks outright, then resolve the real (symlink-followed)
808
+ // path BEFORE the allowlist check. Otherwise a `.png` symlink inside an allowed
809
+ // temp root could point at an arbitrary file (e.g. /etc/shadow) and slip past a
810
+ // string-only containment check, then be copied into the served images dir.
811
+ let lst;
812
+ try { lst = await fs.promises.lstat(resolved); }
813
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
814
+ if (lst.isSymbolicLink()) return jsonResponse(res, 403, { error: 'path_not_allowed' });
815
+ let realPath;
816
+ try { realPath = await fs.promises.realpath(resolved); }
817
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
818
+ if (!_ingestSourceAllowed(realPath)) return jsonResponse(res, 403, { error: 'path_not_allowed' });
819
+ let stat;
820
+ try { stat = await fs.promises.stat(realPath); }
821
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
822
+ if (!stat.isFile() || stat.size <= 0) return jsonResponse(res, 400, { error: 'not_a_file' });
823
+ // Copy (deleteSource omitted → default false): never disturb the user's temp file.
824
+ const _tSave0 = performance.now();
825
+ const result = await db.saveImageFromFile(0, realPath, path.basename(realPath), mimeType);
826
+ _logImageTiming('ingest-path', { bytes: stat.size, roundTripMs: performance.now() - _tSave0 }, result);
827
+ jsonResponse(res, 201, {
828
+ id: result.id,
829
+ filename: result.filename,
830
+ path: result.path,
831
+ url: `/api/images/file/${encodeURIComponent(result.filename)}`,
832
+ });
833
+ } catch (e) {
834
+ jsonResponse(res, 400, { error: e.message });
835
+ }
836
+ }
837
+
656
838
  async function handleUploadMobileAttachment(req, res, url) {
657
839
  try {
658
840
  const kind = url.searchParams.get('kind') === 'image' ? 'image' : 'file';
@@ -665,9 +847,11 @@ async function handleUploadMobileAttachment(req, res, url) {
665
847
  jsonResponse(res, 400, { error: 'attachment_empty' });
666
848
  return;
667
849
  }
668
- const result = await db.saveImage(0, buffer, storedFilename, mimeType);
850
+ const _tSave0 = performance.now();
851
+ const result = await saveImageViaOwner(0, buffer, storedFilename, mimeType);
852
+ const safe = _logImageTiming('mobile', { bytes: buffer.length, roundTripMs: performance.now() - _tSave0 }, result);
669
853
  jsonResponse(res, 201, {
670
- ...result,
854
+ ...safe,
671
855
  kind,
672
856
  originalName: filename,
673
857
  mimeType,
@@ -869,13 +1053,30 @@ function handleListConversations(req, res, url) {
869
1053
 
870
1054
  // Core import logic shared by API handler and auto-import
871
1055
  const { getAllSessionFiles, getAllSessionFilesAsync, parseSessionFile } = require('./session-utils');
1056
+ const walleTranscript = require('./lib/walle-transcript');
1057
+ const { readWalleCtmHistory } = require('./lib/walle-ctm-history');
1058
+ const {
1059
+ persistWalleSessionConversation,
1060
+ WALLE_SESSION_CACHE_PARSER_VERSION,
1061
+ } = require('./lib/walle-session-cache');
872
1062
  const {
873
- codexUserKey,
1063
+ createCodexUserDeduper,
1064
+ parseCodexJsonlFileIntoMessagesAsync,
874
1065
  parseCodexJsonlFileIntoMessages,
875
1066
  parseCodexJsonlIntoMessages,
876
1067
  readCodexRolloutMetadata,
877
1068
  } = require('./lib/session-history');
878
1069
  const fsp = require('fs').promises;
1070
+ const CODEX_CONVERSATION_IMPORT_PARSER_VERSION = 3;
1071
+ const DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION = 1;
1072
+ // Claude Code / Claude Desktop sessions are Anthropic, but their JSONL carries no explicit
1073
+ // `modelProvider` field, so the parser leaves it ''. Storing '' makes the import's
1074
+ // `missingModel` candidate predicate (`!existing.model_provider`) re-select the session on
1075
+ // EVERY scan tick forever — re-stringifying + rewriting the whole conversation each time
1076
+ // (the re-import storm: 75 sessions, db-owner-worker saturation, write-lock contention).
1077
+ // Default to a concrete provider so the row converges after one import. (Codex uses 'openai'
1078
+ // and Cursor 'cursor' at their own call sites.)
1079
+ const CLAUDE_MODEL_PROVIDER = 'anthropic';
879
1080
 
880
1081
  // Parse JSONL content string into conversation messages (sync, CPU-bound but
881
1082
  // called after async file read so only the parse blocks — not disk I/O).
@@ -981,7 +1182,7 @@ function _parseLargeConversationLine(line) {
981
1182
  return null;
982
1183
  }
983
1184
 
984
- async function _parseConversationContent(content) {
1185
+ async function _parseConversationContent(content, opts = {}) {
985
1186
  const messages = [];
986
1187
  const searchMessages = [];
987
1188
  let assistantCount = 0;
@@ -990,6 +1191,29 @@ async function _parseConversationContent(content) {
990
1191
  let firstAssistantText = '';
991
1192
  let renameName = '';
992
1193
 
1194
+ // Cross-file stitching (mirrors parseSessionFiles in lib/jsonl-conversation-parser):
1195
+ // a post-compact Claude file inherits the PARENT file's lines (old sessionId at the
1196
+ // top). When the owner file still exists next to this one, drop the inherited copy
1197
+ // so the owner's import is the only one that stores those turns. Orphan prefixes
1198
+ // (owner deleted) are kept — they are the only surviving copy.
1199
+ const fileSessionId = String(opts.fileSessionId || '');
1200
+ const fileDir = String(opts.fileDir || '');
1201
+ const ownerOnDisk = new Map();
1202
+ const dropInheritedEntry = (entryId) => {
1203
+ if (!fileSessionId || !fileDir || !entryId || entryId === fileSessionId) return false;
1204
+ if (!ownerOnDisk.has(entryId)) {
1205
+ let exists = false;
1206
+ try { exists = fs.existsSync(path.join(fileDir, `${entryId}.jsonl`)); } catch {}
1207
+ ownerOnDisk.set(entryId, exists);
1208
+ }
1209
+ return ownerOnDisk.get(entryId);
1210
+ };
1211
+ const seenUuids = new Set();
1212
+ const summaries = [];
1213
+ // Last contiguous run emitted for one assistant parentUuid (structured
1214
+ // emission replaces the whole run when the same row re-streams).
1215
+ let lastAssistantRun = null;
1216
+
993
1217
  // Yield while scanning so a large JSONL does not monopolize the event loop.
994
1218
  // Avoid content.split('\n') here: building the full line array for a 50MB+
995
1219
  // file blocks before the parser gets its first chance to yield.
@@ -1014,6 +1238,12 @@ async function _parseConversationContent(content) {
1014
1238
  i++;
1015
1239
  try {
1016
1240
  if (line.length > _LARGE_JSONL_LINE_BYTES) {
1241
+ if (dropInheritedEntry(_readJsonStringPrefix(line, '"sessionId":"', 64))) {
1242
+ await maybeYield();
1243
+ continue;
1244
+ }
1245
+ const largeUuid = _readJsonStringPrefix(line, '"uuid":"', 64);
1246
+ if (largeUuid) seenUuids.add(largeUuid);
1017
1247
  const large = _parseLargeConversationLine(line);
1018
1248
  if (large?.role === 'user') {
1019
1249
  messages.push({ role: 'user', text: large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: large.timestamp });
@@ -1044,11 +1274,64 @@ async function _parseConversationContent(content) {
1044
1274
  }
1045
1275
 
1046
1276
  const entry = JSON.parse(line);
1047
- if (entry.type === 'user' && entry.message?.role === 'user') {
1277
+ if (dropInheritedEntry(typeof entry.sessionId === 'string' ? entry.sessionId : '')) {
1278
+ await maybeYield();
1279
+ continue;
1280
+ }
1281
+ if (typeof entry.uuid === 'string' && entry.uuid) seenUuids.add(entry.uuid);
1282
+ if (entry.type === 'summary' && typeof entry.summary === 'string' && entry.summary.trim()) {
1283
+ // Async-generated session title ({summary, leafUuid}); not a turn.
1284
+ summaries.push({ summary: entry.summary.trim(), leafUuid: entry.leafUuid || '' });
1285
+ } else if (entry.type === 'system' && entry.subtype === 'compact_boundary'
1286
+ && structuredCapture.structuredCaptureEnabled()) {
1287
+ const compactMeta = entry.compactMetadata || {};
1288
+ const boundary = structuredCapture.compactBoundaryMessage({
1289
+ provider: 'claude',
1290
+ trigger: compactMeta.trigger,
1291
+ preTokens: Number(compactMeta.preTokens),
1292
+ logicalParentUuid: entry.logicalParentUuid,
1293
+ timestamp: entry.timestamp,
1294
+ });
1295
+ messages.push(boundary);
1296
+ searchMessages.push(boundary);
1297
+ } else if (entry.type === 'user' && entry.message?.role === 'user') {
1048
1298
  const c = entry.message.content;
1049
1299
  const text = typeof c === 'string' ? c
1050
1300
  : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
1051
- if (text) {
1301
+ const captureOn = structuredCapture.structuredCaptureEnabled();
1302
+ if (captureOn && (entry.isCompactSummary || entry.isVisibleInTranscriptOnly)) {
1303
+ // Post-compact synthetic context summary — a collapsed structured
1304
+ // row, never a user prompt (keeps first/last-user title signals clean).
1305
+ if (text) {
1306
+ const summaryMsg = structuredCapture.compactSummaryMessage({ provider: 'claude', text, timestamp: entry.timestamp });
1307
+ messages.push(summaryMsg);
1308
+ searchMessages.push(summaryMsg);
1309
+ }
1310
+ } else if (captureOn && Array.isArray(c) && c.some(b => b && b.type === 'tool_result')) {
1311
+ // Mirrors lib/jsonl-conversation-parser: one structured tool_result
1312
+ // row per block, enriched from the top-level toolUseResult.
1313
+ const resultBlocks = c.filter(b => b && b.type === 'tool_result');
1314
+ const tur = resultBlocks.length === 1 && entry.toolUseResult && typeof entry.toolUseResult === 'object'
1315
+ ? entry.toolUseResult : null;
1316
+ for (const block of resultBlocks) {
1317
+ const blockText = typeof block.content === 'string' ? block.content
1318
+ : Array.isArray(block.content)
1319
+ ? block.content.filter(b => b && b.type === 'text' && b.text).map(b => b.text).join('\n')
1320
+ : '';
1321
+ const resultMsg = structuredCapture.toolResultMessage({
1322
+ provider: 'claude',
1323
+ callId: block.tool_use_id,
1324
+ output: blockText || (tur && typeof tur.stdout === 'string' ? tur.stdout : ''),
1325
+ isError: block.is_error === true,
1326
+ filePath: tur ? (tur.filePath || tur.file_path) : undefined,
1327
+ durationMs: tur ? Number(tur.durationMs) : undefined,
1328
+ structuredPatch: tur ? tur.structuredPatch : undefined,
1329
+ timestamp: entry.timestamp,
1330
+ });
1331
+ messages.push(resultMsg);
1332
+ searchMessages.push(resultMsg);
1333
+ }
1334
+ } else if (text) {
1052
1335
  messages.push({ role: 'user', text: text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: entry.timestamp });
1053
1336
  searchMessages.push({ role: 'user', text, timestamp: entry.timestamp });
1054
1337
  if (!firstUserContent) firstUserContent = text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
@@ -1060,6 +1343,80 @@ async function _parseConversationContent(content) {
1060
1343
  } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
1061
1344
  const c = entry.message.content;
1062
1345
  if (!Array.isArray(c)) continue;
1346
+ if (structuredCapture.structuredCaptureEnabled()) {
1347
+ // Mirrors lib/jsonl-conversation-parser: thinking → reasoning rows,
1348
+ // tool_use → tool_call rows, text blocks → ONE assistant message
1349
+ // (parentUuid + usage/model metadata), in block order. A re-streamed
1350
+ // parentUuid replaces the previous contiguous run.
1351
+ const run = [];
1352
+ const textParts = [];
1353
+ let textInsertIndex = -1;
1354
+ for (const block of c) {
1355
+ if (!block || typeof block !== 'object') continue;
1356
+ if (block.type === 'text' && block.text) {
1357
+ if (textInsertIndex === -1) textInsertIndex = run.length;
1358
+ textParts.push(block.text);
1359
+ } else if (block.type === 'thinking' && (block.thinking || block.text)) {
1360
+ run.push(structuredCapture.reasoningMessage({ provider: 'claude', text: block.thinking || block.text, timestamp: entry.timestamp }));
1361
+ } else if (block.type === 'tool_use') {
1362
+ run.push(structuredCapture.toolCallMessage({
1363
+ provider: 'claude', tool: block.name, callId: block.id, args: block.input, timestamp: entry.timestamp,
1364
+ }));
1365
+ }
1366
+ }
1367
+ let searchText = '';
1368
+ if (textParts.length) {
1369
+ searchText = textParts.join('\n');
1370
+ if (!firstAssistantText) firstAssistantText = searchText.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
1371
+ const msg = {
1372
+ role: 'assistant',
1373
+ text: searchText.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT),
1374
+ timestamp: entry.timestamp,
1375
+ parentUuid: entry.parentUuid,
1376
+ _parent: entry.parentUuid,
1377
+ };
1378
+ const usage = _usageMetadata(entry.message.usage);
1379
+ const model = typeof entry.message.model === 'string' ? entry.message.model : '';
1380
+ if (usage || model) {
1381
+ msg.metadata = {};
1382
+ if (usage) msg.metadata.usage = usage;
1383
+ if (model) msg.metadata.model = model;
1384
+ }
1385
+ run.splice(textInsertIndex === -1 ? run.length : textInsertIndex, 0, msg);
1386
+ }
1387
+ if (run.length) {
1388
+ if (entry.parentUuid && lastAssistantRun
1389
+ && lastAssistantRun.parent === entry.parentUuid
1390
+ && lastAssistantRun.mEnd === messages.length
1391
+ && lastAssistantRun.sEnd === searchMessages.length) {
1392
+ messages.splice(lastAssistantRun.mStart);
1393
+ searchMessages.splice(lastAssistantRun.sStart);
1394
+ assistantCount -= lastAssistantRun.assistantCount;
1395
+ }
1396
+ const mStart = messages.length;
1397
+ const sStart = searchMessages.length;
1398
+ let runAssistantCount = 0;
1399
+ for (const m of run) {
1400
+ messages.push(m);
1401
+ if (m.role === 'assistant') {
1402
+ runAssistantCount++;
1403
+ // Search index keeps the FULL text (the messages copy is capped).
1404
+ searchMessages.push({ ...m, text: searchText });
1405
+ } else {
1406
+ searchMessages.push(m);
1407
+ }
1408
+ }
1409
+ assistantCount += runAssistantCount;
1410
+ lastAssistantRun = {
1411
+ parent: entry.parentUuid,
1412
+ mStart, mEnd: messages.length,
1413
+ sStart, sEnd: searchMessages.length,
1414
+ assistantCount: runAssistantCount,
1415
+ };
1416
+ }
1417
+ await maybeYield();
1418
+ continue;
1419
+ }
1063
1420
  const parts = [];
1064
1421
  for (const block of c) {
1065
1422
  if (block.type === 'text' && block.text) parts.push(block.text);
@@ -1094,6 +1451,12 @@ async function _parseConversationContent(content) {
1094
1451
  }
1095
1452
  messages.forEach(m => delete m._parent);
1096
1453
  searchMessages.forEach(m => delete m._parent);
1454
+ // Title candidate (lowest precedence): the last summary whose leafUuid we
1455
+ // actually saw in this content — or one with no leafUuid at all.
1456
+ let summaryTitle = '';
1457
+ for (const s of summaries) {
1458
+ if (!s.leafUuid || seenUuids.has(s.leafUuid)) summaryTitle = s.summary.slice(0, 200);
1459
+ }
1097
1460
  return {
1098
1461
  messages,
1099
1462
  searchMessages,
@@ -1102,6 +1465,7 @@ async function _parseConversationContent(content) {
1102
1465
  lastUserContent,
1103
1466
  firstAssistantText,
1104
1467
  renameName,
1468
+ summaryTitle,
1105
1469
  };
1106
1470
  }
1107
1471
 
@@ -1157,8 +1521,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1157
1521
  fsp.readFile(jsonlPath, 'utf8').catch(() => ''),
1158
1522
  ]);
1159
1523
 
1160
- const bakParsed = await _parseConversationContent(bakContent);
1161
- const jsonlParsed = await _parseConversationContent(jsonlContent);
1524
+ const stitchOpts = { fileSessionId: claudeFileSessionId(jsonlPath), fileDir: path.dirname(jsonlPath) };
1525
+ const bakParsed = await _parseConversationContent(bakContent, stitchOpts);
1526
+ const jsonlParsed = await _parseConversationContent(jsonlContent, stitchOpts);
1162
1527
 
1163
1528
  // Concatenate then sort by timestamp so out-of-order writes (rare but
1164
1529
  // possible mid-compact) end up in chronological order.
@@ -1189,7 +1554,7 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1189
1554
  search_messages: allSearchMessages,
1190
1555
  user_msg_count: signals.userCount,
1191
1556
  assistant_msg_count: signals.assistantCount,
1192
- title: parsed.title || (existing && existing.title) || '',
1557
+ title: parsed.title || (existing && existing.title) || jsonlParsed.summaryTitle || bakParsed.summaryTitle || '',
1193
1558
  first_message: mergedFirstUser,
1194
1559
  last_user_content: mergedLastUser,
1195
1560
  first_assistant_text: mergedFirstAssistant,
@@ -1198,8 +1563,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1198
1563
  file_size: totalSize,
1199
1564
  session_created_at: parsed.timestamp,
1200
1565
  hostname: parsed.hostname,
1201
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
1566
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1202
1567
  model_id: parsed.modelId || (existing && existing.model_id) || '',
1568
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1203
1569
  });
1204
1570
  return true;
1205
1571
  }
@@ -1232,15 +1598,8 @@ function _loadIndexedSessionMessages(sessionId) {
1232
1598
  }
1233
1599
  }
1234
1600
 
1235
- function _codexSeenUsersFromMessages(messages) {
1236
- const seen = new Set();
1237
- for (const msg of Array.isArray(messages) ? messages : []) {
1238
- if (msg && msg.role === 'user') {
1239
- const key = codexUserKey(msg.text || msg.content || '');
1240
- if (key) seen.add(key);
1241
- }
1242
- }
1243
- return seen;
1601
+ function _codexUserDeduperFromMessages(messages) {
1602
+ return createCodexUserDeduper((Array.isArray(messages) ? messages : []).filter(msg => msg && msg.role === 'user'));
1244
1603
  }
1245
1604
 
1246
1605
  async function _readFileRange(filePath, start, length) {
@@ -1255,21 +1614,28 @@ async function _readFileRange(filePath, start, length) {
1255
1614
  }
1256
1615
 
1257
1616
  function _conversationImportIndexRows() {
1258
- const conversations = new Map();
1259
- const linkedAgentIds = new Set();
1260
- try {
1261
- const rows = db.getDb().prepare(
1262
- 'SELECT ctm_session_id, file_size, model_provider FROM session_conversations'
1263
- ).all();
1264
- for (const row of rows) conversations.set(row.ctm_session_id, row);
1265
- } catch {}
1266
- try {
1267
- const rows = db.getDb().prepare(
1268
- 'SELECT agent_session_id FROM agent_sessions WHERE agent_session_id IS NOT NULL AND agent_session_id != ""'
1269
- ).all();
1270
- for (const row of rows) linkedAgentIds.add(row.agent_session_id);
1271
- } catch {}
1272
- return { conversations, linkedAgentIds };
1617
+ // Attribution: two full-table index scans built on every conversation-import
1618
+ // tick. They run as the sync prefix of _conversationImportCandidates, which is
1619
+ // itself called AFTER the job's `await getAllSessionFilesAsync()` — so the
1620
+ // scheduler's tag is already cleared and a slow scan would log as `(unknown)`.
1621
+ // Phase 2.
1622
+ return runSync('conversation-import:buildIndexRows', () => {
1623
+ const conversations = new Map();
1624
+ const linkedAgentIds = new Set();
1625
+ try {
1626
+ const rows = db.getDb().prepare(
1627
+ 'SELECT ctm_session_id, file_size, model_provider, import_parser_version FROM session_conversations'
1628
+ ).all();
1629
+ for (const row of rows) conversations.set(row.ctm_session_id, row);
1630
+ } catch {}
1631
+ try {
1632
+ const rows = db.getDb().prepare(
1633
+ 'SELECT agent_session_id FROM agent_sessions WHERE agent_session_id IS NOT NULL AND agent_session_id != ""'
1634
+ ).all();
1635
+ for (const row of rows) linkedAgentIds.add(row.agent_session_id);
1636
+ } catch {}
1637
+ return { conversations, linkedAgentIds };
1638
+ });
1273
1639
  }
1274
1640
 
1275
1641
  async function _conversationImportEffectiveSize(filePath, stat) {
@@ -1296,14 +1662,29 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1296
1662
  const stat = await fsp.stat(claudeDesktopSessions.sourcePathForStat(filePath));
1297
1663
  if (!stat.isFile()) continue;
1298
1664
  const sessionId = listedSessionId || path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
1665
+ // Desktop sessions re-candidate forever via `linkedMissingCache`: importSessionFile
1666
+ // returns false for an empty desktop session (no messages), so no session_conversations
1667
+ // row is ever written, so `!existing` stays true and the session is re-selected every
1668
+ // import tick. Skip dead/empty desktop sessions here — there is nothing to import, and a
1669
+ // healthy one (messages present) writes a cache row and stops matching this guard.
1670
+ if (claudeDesktopSessions.isVirtualSessionPath(filePath)
1671
+ && (claudeDesktopSessions.getMessages(sessionId) || []).length === 0) {
1672
+ continue;
1673
+ }
1299
1674
  const existing = conversations.get(sessionId);
1300
1675
  const existingSize = Number(existing?.file_size || 0);
1301
1676
  const { effectiveSize, hasCompactSibling } = await _conversationImportEffectiveSize(filePath, stat);
1677
+ const isCodexRollout = projectEntry === 'codex' || String(filePath || '').includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
1678
+ const isWalleTranscript = projectEntry === walleTranscript.WALLE_PROJECT_ENTRY;
1302
1679
  const changedSinceScan = stat.mtimeMs > lastScanAt;
1303
1680
  const cacheBehind = !!existing && effectiveSize > existingSize;
1304
1681
  const cacheShrankAfterChange = !!existing && effectiveSize < existingSize && changedSinceScan;
1305
1682
  const linkedMissingCache = linkedAgentIds.has(sessionId) && !existing;
1306
1683
  const missingModel = !!existing && !existing.model_provider;
1684
+ const staleParser = !!existing && (
1685
+ (isCodexRollout && Number(existing.import_parser_version || 0) < CODEX_CONVERSATION_IMPORT_PARSER_VERSION) ||
1686
+ (isWalleTranscript && Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION)
1687
+ );
1307
1688
  const changedColdFile = changedSinceScan && !existing;
1308
1689
 
1309
1690
  if (
@@ -1311,18 +1692,20 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1311
1692
  !cacheBehind &&
1312
1693
  !cacheShrankAfterChange &&
1313
1694
  !linkedMissingCache &&
1314
- !missingModel
1695
+ !missingModel &&
1696
+ !staleParser
1315
1697
  ) {
1316
1698
  continue;
1317
1699
  }
1318
1700
 
1319
1701
  let priority = 6;
1320
1702
  if (cacheBehind) priority = 0;
1321
- else if (linkedMissingCache) priority = 1;
1322
- else if (cacheShrankAfterChange) priority = 2;
1323
- else if (hasCompactSibling && changedColdFile) priority = 3;
1324
- else if (changedColdFile) priority = 4;
1325
- else if (missingModel) priority = 5;
1703
+ else if (staleParser) priority = 1;
1704
+ else if (linkedMissingCache) priority = 2;
1705
+ else if (cacheShrankAfterChange) priority = 3;
1706
+ else if (hasCompactSibling && changedColdFile) priority = 4;
1707
+ else if (changedColdFile) priority = 5;
1708
+ else if (missingModel) priority = 6;
1326
1709
 
1327
1710
  candidates.push({
1328
1711
  filePath,
@@ -1356,32 +1739,79 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1356
1739
  return candidates;
1357
1740
  }
1358
1741
 
1359
- async function _importCodexSessionFile(parsed, filePath) {
1742
+ function _backgroundTranscriptImportMaxBytes() {
1743
+ const raw = Number(process.env.CTM_BACKGROUND_TRANSCRIPT_IMPORT_MAX_BYTES || BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
1744
+ return Math.max(256 * 1024, Number.isFinite(raw) ? raw : BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
1745
+ }
1746
+
1747
+ function _codexImportedFileSize(parsedFileSize, prevFileSize, parsedTail) {
1748
+ const fileSize = Math.max(0, Number(parsedFileSize || 0));
1749
+ const prev = Math.max(0, Number(prevFileSize || 0));
1750
+ const base = prev > 0 && fileSize >= prev ? prev : 0;
1751
+ const rawConsumed = Number.isFinite(Number(parsedTail?.completeBytesRead))
1752
+ ? Number(parsedTail.completeBytesRead)
1753
+ : Number(parsedTail?.bytesRead || 0);
1754
+ const consumed = Math.max(0, Number.isFinite(rawConsumed) ? rawConsumed : 0);
1755
+ return Math.min(fileSize, base + consumed);
1756
+ }
1757
+
1758
+ async function _importCodexSessionFile(parsed, filePath, options = {}) {
1360
1759
  const sessionId = parsed.sessionId;
1361
1760
  if (!sessionId) return false;
1362
1761
 
1363
1762
  const existing = db.getSessionConversation(sessionId);
1364
- if (existing && existing.file_size === parsed.fileSize && existing.model_provider) return false;
1365
-
1366
- const prevFileSize = Number(existing?.file_size || 0);
1367
- const baseMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1368
- ? _safeParseMessagesJson(existing.messages)
1369
- : [];
1370
- const indexedMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1371
- ? _loadIndexedSessionMessages(sessionId)
1763
+ const parserVersion = CODEX_CONVERSATION_IMPORT_PARSER_VERSION;
1764
+ const parserStale = !!existing && Number(existing.import_parser_version || 0) < parserVersion;
1765
+ if (existing && existing.file_size === parsed.fileSize && existing.model_provider && !parserStale) return false;
1766
+
1767
+ const prevFileSize = parserStale ? 0 : Number(existing?.file_size || 0);
1768
+ // Hard size cap (fail-open): a multi-GB transcript would make the existing-blob
1769
+ // parse + whole-array concat/sort below block the loop and exceed V8's ~512MB
1770
+ // string limit. Over cap ⇒ don't load the full base from the blob; import only
1771
+ // the freshly-read tail (the downstream blob write is also capped to '[]' and
1772
+ // the transcript-store rows carry full history).
1773
+ let _isOverParseCap = null;
1774
+ try { _isOverParseCap = require('./lib/size-cap').isOverParseCap; } catch { /* optional */ }
1775
+ const _overCap = typeof _isOverParseCap === 'function' ? _isOverParseCap(parsed.fileSize) : false;
1776
+ const _loadBase = !_overCap && prevFileSize > 0 && parsed.fileSize > prevFileSize;
1777
+ // Phase 6: load the incremental base from the faithful per-message rows when they're
1778
+ // known-complete (column read, no multi-MB JSON.parse of the blob — and it survives blob
1779
+ // retirement in Phase 7). Falls back to the blob when rows are absent/half-migrated.
1780
+ const _rowsComplete = _loadBase
1781
+ && typeof db.sessionContentRowsAvailable === 'function'
1782
+ && db.sessionContentRowsAvailable(sessionId);
1783
+ const baseMessages = _loadBase
1784
+ ? (_rowsComplete ? db.getSessionMessagesArray(sessionId, { fallbackToBlob: false }) : _safeParseMessagesJson(existing.messages))
1372
1785
  : [];
1373
- const baseSearchMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1786
+ const indexedMessages = _loadBase ? _loadIndexedSessionMessages(sessionId) : [];
1787
+ const baseSearchMessages = _loadBase
1374
1788
  ? (indexedMessages.length ? indexedMessages : baseMessages)
1375
1789
  : [];
1376
- const seenUsers = _codexSeenUsersFromMessages(baseMessages);
1790
+ const codexUserDeduper = _codexUserDeduperFromMessages(baseMessages);
1377
1791
 
1378
1792
  const newMessages = [];
1379
1793
  let parsedTail;
1380
1794
  if (prevFileSize > 0 && parsed.fileSize > prevFileSize) {
1381
1795
  const content = await _readFileRange(filePath, prevFileSize, parsed.fileSize - prevFileSize);
1382
- parsedTail = parseCodexJsonlIntoMessages(content, newMessages, { seenUsers });
1796
+ parsedTail = parseCodexJsonlIntoMessages(content, newMessages, { codexUserDeduper });
1797
+ } else if (options.cooperative) {
1798
+ parsedTail = await parseCodexJsonlFileIntoMessagesAsync(filePath, newMessages, {
1799
+ codexUserDeduper,
1800
+ yieldAfterMs: options.yieldAfterMs || 25,
1801
+ });
1383
1802
  } else {
1384
- parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, { seenUsers });
1803
+ parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, { codexUserDeduper });
1804
+ }
1805
+
1806
+ // Re-import storm guard: codex `importedFileSize` tracks CONSUMED bytes, which can stay below
1807
+ // parsed.fileSize indefinitely (trailing non-message lines / a partial record still being
1808
+ // written), so the candidate selector's `cacheBehind` (effectiveSize > stored file_size) never
1809
+ // converges and this importer is re-run every scan tick. When the grown tail held NO new
1810
+ // messages, the whole-conversation re-stringify + replaceSessionMessages below is pure waste
1811
+ // that saturates the db-owner worker — skip it. The resume offset (stored file_size) is left
1812
+ // unchanged, so a record that only completes later is still picked up on a subsequent pass.
1813
+ if (prevFileSize > 0 && parsed.fileSize > prevFileSize && newMessages.length === 0) {
1814
+ return false;
1385
1815
  }
1386
1816
 
1387
1817
  const allMessages = baseMessages.concat(newMessages);
@@ -1392,6 +1822,8 @@ async function _importCodexSessionFile(parsed, filePath) {
1392
1822
  const assistantMessages = allMessages.filter(m => m.role === 'assistant' && (m.text || m.content));
1393
1823
  if (allMessages.length === 0 || userMessages.length === 0) return false;
1394
1824
 
1825
+ const importedFileSize = _codexImportedFileSize(parsed.fileSize, prevFileSize, parsedTail);
1826
+
1395
1827
  const fileMeta = readCodexRolloutMetadata(filePath) || {};
1396
1828
  const meta = parsedTail.sessionMeta || fileMeta || {};
1397
1829
  const firstUser = userMessages[0]?.text || userMessages[0]?.content || '';
@@ -1417,14 +1849,45 @@ async function _importCodexSessionFile(parsed, filePath) {
1417
1849
  first_assistant_text: firstAssistant.slice(0, 500),
1418
1850
  rename_name: existing?.rename_name || '',
1419
1851
  git_branch: meta.git_branch || parsed.gitBranch || '',
1420
- file_size: parsed.fileSize,
1852
+ file_size: importedFileSize,
1421
1853
  session_created_at: meta.timestamp || parsed.timestamp || '',
1422
1854
  hostname: parsed.hostname,
1423
1855
  model_provider: modelProvider,
1424
1856
  model_id: model || (existing && existing.model_id) || '',
1857
+ import_parser_version: parserVersion,
1425
1858
  });
1426
1859
 
1427
1860
  try {
1861
+ const threadSource = String(meta.thread_source || meta.threadSource || '').trim().toLowerCase();
1862
+ const parentAgentSessionId = String(
1863
+ meta.parent_agent_session_id
1864
+ || meta.parentAgentSessionId
1865
+ || meta.parent_thread_id
1866
+ || meta.parentThreadId
1867
+ || ''
1868
+ ).trim();
1869
+ if (threadSource === 'subagent' && parentAgentSessionId) {
1870
+ db.upsertAgentSessionIdentity(sessionId, {
1871
+ provider: 'codex',
1872
+ providerResumeId: sessionId,
1873
+ cwd: projectPath,
1874
+ projectPath,
1875
+ title,
1876
+ jsonlPath: filePath,
1877
+ fileSize: parsed.fileSize,
1878
+ modifiedAt: parsed.modifiedAt,
1879
+ model,
1880
+ gitBranch: meta.git_branch || parsed.gitBranch || '',
1881
+ hostname: parsed.hostname,
1882
+ userMsgCount: userMessages.length,
1883
+ firstMessage: firstUser.slice(0, 500),
1884
+ threadSource,
1885
+ parentAgentSessionId,
1886
+ agentNickname: meta.agent_nickname || meta.agentNickname || '',
1887
+ agentRole: meta.agent_role || meta.agentRole || '',
1888
+ });
1889
+ return true;
1890
+ }
1428
1891
  const owner = db.getDb().prepare('SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?').get(sessionId);
1429
1892
  db.upsertSession(owner?.ctm_session_id || sessionId, {
1430
1893
  agentSessionId: sessionId,
@@ -1449,21 +1912,28 @@ async function _importCodexSessionFile(parsed, filePath) {
1449
1912
  return true;
1450
1913
  }
1451
1914
 
1452
- function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
1915
+ function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
1916
+ // Claude Desktop sessions use a virtual path (`…#ctm-claude-desktop=<uuid>`) that is never a
1917
+ // real file on disk — fs.statSync / ingestJsonlFile always throw ENOENT on it. Their
1918
+ // transcript data lives in the Desktop cache (read via getMessages), not the JSONL transcript
1919
+ // store, so this ingest can only ever fail for them. Skipping removes a guaranteed-failing
1920
+ // synchronous statSync that was firing ~97×/sec in a hot loop on dead desktop sessions.
1921
+ if (claudeDesktopSessions.isVirtualSessionPath(filePath)) return null;
1453
1922
  try {
1454
1923
  const size = Number(parsed?.fileSize || fs.statSync(filePath).size || 0);
1455
1924
  const agentSessionId = String(parsed?.sessionId || transcriptSourceIdFromPath(filePath) || '').trim();
1456
1925
  if (!agentSessionId) return null;
1457
1926
  const provider = normalizeTranscriptProvider(parsed?.agent || parsed?.modelProvider || '', filePath);
1458
1927
  const largeColdMode = size >= TRANSCRIPT_IMPORT_LARGE_FILE_BYTES ? 'tail' : undefined;
1928
+ const maxBytes = Math.max(256 * 1024, Number(options.transcriptMaxBytes || TRANSCRIPT_IMPORT_MAX_BYTES));
1459
1929
  const result = ingestJsonlFile(db.getDb(), {
1460
1930
  filePath,
1461
1931
  agentSessionId,
1462
1932
  ctmSessionId: agentSessionId,
1463
1933
  provider,
1464
1934
  mode: largeColdMode,
1465
- initialTailBytes: TRANSCRIPT_IMPORT_MAX_BYTES,
1466
- maxBytes: largeColdMode ? TRANSCRIPT_IMPORT_MAX_BYTES : Math.min(size || TRANSCRIPT_IMPORT_MAX_BYTES, TRANSCRIPT_IMPORT_MAX_BYTES),
1935
+ initialTailBytes: maxBytes,
1936
+ maxBytes: largeColdMode ? maxBytes : Math.min(size || maxBytes, maxBytes),
1467
1937
  });
1468
1938
  if ((result.inserted || 0) > 0 || (result.bytesRead || 0) > 0) {
1469
1939
  console.log(
@@ -1478,11 +1948,55 @@ function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
1478
1948
  }
1479
1949
  }
1480
1950
 
1481
- async function importSessionFile(filePath, projectPath, projectEntry) {
1951
+ async function _importWalleSessionFile(parsed, filePath) {
1952
+ const sessionId = String(parsed?.sessionId || '').trim();
1953
+ if (!sessionId) return false;
1954
+
1955
+ const existing = db.getSessionConversation(sessionId);
1956
+ const fileStat = await fsp.stat(filePath).catch(() => null);
1957
+ if (!fileStat || !fileStat.isFile()) return false;
1958
+ const parserStale = !!existing &&
1959
+ Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION;
1960
+ if (existing && existing.file_size === fileStat.size && existing.model_provider && !parserStale) {
1961
+ return false;
1962
+ }
1963
+
1964
+ const history = readWalleCtmHistory(filePath);
1965
+ if (!history.some((message) => message && message.role === 'user' && (message.content || message.text))) {
1966
+ return false;
1967
+ }
1968
+ const meta = walleTranscript.readSessionMeta(filePath) || {};
1969
+ const chatSessionId = String(meta.chatSessionId || meta.sessionId || sessionId).trim() || sessionId;
1970
+ const wrote = persistWalleSessionConversation({
1971
+ db,
1972
+ source: {
1973
+ ...parsed,
1974
+ ctmSessionId: sessionId,
1975
+ conversationSessionId: sessionId,
1976
+ agentSessionId: sessionId,
1977
+ chatSessionId,
1978
+ jsonlPath: filePath,
1979
+ cwd: meta.cwd || parsed.cwd || parsed.project || '',
1980
+ title: meta.label || parsed.title || '',
1981
+ label: meta.label || parsed.title || '',
1982
+ model_provider: meta.modelProvider || parsed.modelProvider || '',
1983
+ model_id: meta.modelId || parsed.modelId || '',
1984
+ },
1985
+ history,
1986
+ fileStat,
1987
+ hostname: parsed.hostname,
1988
+ });
1989
+ return wrote > 0;
1990
+ }
1991
+
1992
+ async function importSessionFile(filePath, projectPath, projectEntry, options = {}) {
1482
1993
  const parsed = parseSessionFile(filePath, projectPath, projectEntry);
1483
- _ingestTranscriptStoreForParsedFile(filePath, parsed);
1994
+ _ingestTranscriptStoreForParsedFile(filePath, parsed, options);
1484
1995
  if (parsed.agent === 'codex') {
1485
- return _importCodexSessionFile(parsed, filePath);
1996
+ return _importCodexSessionFile(parsed, filePath, options);
1997
+ }
1998
+ if (parsed.agent === 'walle') {
1999
+ return _importWalleSessionFile(parsed, filePath);
1486
2000
  }
1487
2001
  if (parsed.agent === claudeDesktopSessions.DESKTOP_AGENT) {
1488
2002
  const messages = claudeDesktopSessions.getMessages(parsed.sessionId) || [];
@@ -1510,8 +2024,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1510
2024
  file_size: parsed.fileSize,
1511
2025
  session_created_at: parsed.timestamp,
1512
2026
  hostname: parsed.hostname,
1513
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
2027
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1514
2028
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2029
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1515
2030
  });
1516
2031
  return true;
1517
2032
  }
@@ -1555,8 +2070,16 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1555
2070
  } finally {
1556
2071
  await fh.close();
1557
2072
  }
1558
- // Carry forward existing parsed messages
1559
- try { baseMessages = JSON.parse(existing.messages || '[]'); } catch {}
2073
+ // Carry forward existing parsed messages. Phase 6: load the base from the faithful
2074
+ // per-message rows when known-complete (column read, no multi-MB JSON.parse and it
2075
+ // survives blob retirement in Phase 7); fall back to the blob when rows aren't ready.
2076
+ try {
2077
+ if (typeof db.sessionContentRowsAvailable === 'function' && db.sessionContentRowsAvailable(parsed.sessionId)) {
2078
+ baseMessages = db.getSessionMessagesArray(parsed.sessionId, { fallbackToBlob: false });
2079
+ } else {
2080
+ baseMessages = JSON.parse(existing.messages || '[]');
2081
+ }
2082
+ } catch {}
1560
2083
  baseAssistantCount = existing.assistant_msg_count || 0;
1561
2084
  } else {
1562
2085
  // Full read for new files or when file shrank (truncated/rotated)
@@ -1568,7 +2091,11 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1568
2091
  searchMessages: newSearchMessages,
1569
2092
  firstUserContent: parsedFirstUser, lastUserContent: parsedLastUser,
1570
2093
  firstAssistantText: parsedFirstAssistant, renameName: parsedRename,
1571
- } = await _parseConversationContent(content);
2094
+ summaryTitle: parsedSummaryTitle,
2095
+ } = await _parseConversationContent(content, {
2096
+ fileSessionId: claudeFileSessionId(jsonlPath),
2097
+ fileDir: path.dirname(jsonlPath),
2098
+ });
1572
2099
  const allMessages = baseMessages.concat(newMessages);
1573
2100
  const indexedMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
1574
2101
  ? _loadIndexedSessionMessages(parsed.sessionId)
@@ -1600,7 +2127,7 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1600
2127
  search_messages: allSearchMessages,
1601
2128
  user_msg_count: signals.userCount,
1602
2129
  assistant_msg_count: signals.assistantCount || baseAssistantCount + newAssistants,
1603
- title: parsed.title || (existing && existing.title) || '',
2130
+ title: parsed.title || (existing && existing.title) || parsedSummaryTitle || '',
1604
2131
  first_message: mergedFirstUser,
1605
2132
  last_user_content: mergedLastUser,
1606
2133
  first_assistant_text: mergedFirstAssistant,
@@ -1609,8 +2136,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1609
2136
  file_size: parsed.fileSize,
1610
2137
  session_created_at: parsed.timestamp,
1611
2138
  hostname: parsed.hostname,
1612
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
2139
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1613
2140
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2141
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1614
2142
  });
1615
2143
  return true;
1616
2144
  }
@@ -1666,7 +2194,10 @@ async function runIncrementalConversationImport() {
1666
2194
  try {
1667
2195
  if (error) throw error;
1668
2196
  scanned++;
1669
- if (await importSessionFile(filePath, projectPath, projectEntry)) imported++;
2197
+ if (await importSessionFile(filePath, projectPath, projectEntry, {
2198
+ cooperative: true,
2199
+ transcriptMaxBytes: _backgroundTranscriptImportMaxBytes(),
2200
+ })) imported++;
1670
2201
  const importLimited = imported >= maxImportedPerRun;
1671
2202
  const processedLimited = scanned >= maxProcessedPerRun;
1672
2203
  if ((importLimited || processedLimited) && scanned < candidates.length) {
@@ -1708,6 +2239,108 @@ async function runIncrementalConversationImport() {
1708
2239
  }
1709
2240
  }
1710
2241
 
2242
+ // --- Cursor conversation import ----------------------------------------------
2243
+ // Cursor stores conversations as a content-addressed blob graph in its own SQLite
2244
+ // store (~/.cursor/chats/<ws>/<agent>/store.db), NOT a JSONL append log. Reconstructing
2245
+ // it is expensive (blob-graph BFS + per-blob JSON.parse), so — exactly like the JSONL
2246
+ // importer — import once into session_conversations and let the normal cache read path
2247
+ // serve it, instead of reconstructing on every UI poll (the old design; see the
2248
+ // _orderedBlobRefs CPU-profile finding). Signature-gated (store size:mtime) so an unchanged
2249
+ // store is skipped; the store path is resolved once per session and cached on a CONFIDENT
2250
+ // (agentId) match. Runs on the db-owner worker (off the main event loop) like the JSONL import.
2251
+ const CURSOR_IMPORT_PARSER_VERSION = 1; // bump to force a full cursor re-import on shape change
2252
+ const _cursorImportSignatures = new Map(); // ctm_session_id → last-imported store signature
2253
+ const _cursorResolvedStorePaths = new Map(); // ctm_session_id → resolved store.db path (confident match only)
2254
+
2255
+ async function runCursorConversationImport({ cursorHome } = {}) {
2256
+ let imported = 0;
2257
+ let scanned = 0;
2258
+ let skipped = 0;
2259
+ let failed = 0;
2260
+ let total = 0;
2261
+ let cursorStore;
2262
+ try {
2263
+ cursorStore = require('./lib/cursor-conversation-store');
2264
+ } catch (e) {
2265
+ return { imported, scanned, skipped, failed, total, error: `cursor store unavailable: ${e.message}` };
2266
+ }
2267
+ try {
2268
+ const sessions = db.getDb().prepare(
2269
+ "SELECT id, cwd, project_path, created_at, updated_at FROM ctm_sessions WHERE provider = 'cursor'"
2270
+ ).all();
2271
+ total = sessions.length;
2272
+ for (const s of sessions) {
2273
+ try {
2274
+ scanned += 1;
2275
+ const ctmId = String(s.id);
2276
+ const cwd = String(s.cwd || s.project_path || '').trim();
2277
+ const agentRows = db.getDb().prepare(
2278
+ "SELECT agent_session_id FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id IS NOT NULL AND agent_session_id != ''"
2279
+ ).all(ctmId);
2280
+ const agentSessionIds = agentRows.map((r) => String(r.agent_session_id)).filter(Boolean);
2281
+
2282
+ // Resolve the store. If we already have a confident path, gate on its cheap signature
2283
+ // BEFORE the expensive reconstruct; skip when unchanged since the last import.
2284
+ let storeDbPath = _cursorResolvedStorePaths.get(ctmId) || '';
2285
+ if (storeDbPath) {
2286
+ const sig = cursorStore.statSignature(storeDbPath);
2287
+ if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
2288
+ }
2289
+
2290
+ let store = null;
2291
+ if (storeDbPath) {
2292
+ try { store = cursorStore.loadCursorStore(storeDbPath); } catch { store = null; }
2293
+ }
2294
+ if (!store) {
2295
+ // No cached path (or its load failed): resolve via match. loadCursorStore is itself
2296
+ // size:mtime-cached, so the enumeration is cheap for idle stores.
2297
+ store = cursorStore.loadCursorConversationForSession({
2298
+ cwd, createdAt: s.created_at, updatedAt: s.updated_at, agentSessionIds, cursorHome,
2299
+ });
2300
+ if (store && store.storeDbPath) {
2301
+ storeDbPath = store.storeDbPath;
2302
+ // Persist the resolved path ONLY on a confident (agentId) match — cwd+time alone is
2303
+ // a heuristic that could pin the wrong store.
2304
+ if (store.agentId && agentSessionIds.includes(store.agentId)) {
2305
+ _cursorResolvedStorePaths.set(ctmId, storeDbPath);
2306
+ }
2307
+ }
2308
+ }
2309
+ if (!store || !Array.isArray(store.messages) || store.messages.length === 0) continue;
2310
+
2311
+ const sig = storeDbPath ? cursorStore.statSignature(storeDbPath) : '';
2312
+ if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
2313
+
2314
+ const fields = cursorStore.cursorStoreToConversationFields(store);
2315
+ db.importSessionConversation({
2316
+ session_id: ctmId,
2317
+ ...fields,
2318
+ title: '',
2319
+ git_branch: '',
2320
+ file_size: 0, // cursor has no single jsonl footprint; freshness is gated by `sig`
2321
+ session_created_at: fields.session_created_at || s.created_at || '',
2322
+ hostname: '',
2323
+ import_parser_version: CURSOR_IMPORT_PARSER_VERSION,
2324
+ rename_name: '',
2325
+ });
2326
+ if (sig) _cursorImportSignatures.set(ctmId, sig);
2327
+ imported += 1;
2328
+ await new Promise((resolve) => setImmediate(resolve));
2329
+ } catch (e) {
2330
+ failed += 1;
2331
+ console.error(`[cursor-import] ${String(s.id).slice(0, 8)}: ${e.message}`);
2332
+ }
2333
+ }
2334
+ if (imported || failed) {
2335
+ console.log(`[cursor-import] imported ${imported}, skipped ${skipped}, failed ${failed} (scanned ${scanned}/${total})`);
2336
+ }
2337
+ return { imported, scanned, skipped, failed, total };
2338
+ } catch (e) {
2339
+ console.error('[cursor-import] Top-level error:', e.message);
2340
+ return { imported, scanned, skipped, failed, total, error: e.message };
2341
+ }
2342
+ }
2343
+
1711
2344
  function handleGetConversation(req, res, url) {
1712
2345
  const sessionId = url.pathname.split('/').pop();
1713
2346
  const conv = db.getSessionConversation(sessionId);
@@ -1754,7 +2387,7 @@ async function handleGetSettings(req, res, url) {
1754
2387
  for (const r of rows) result[r.key] = r.value;
1755
2388
  return result;
1756
2389
  }
1757
- const allKeys = ['db_path', 'images_dir', 'default_context_type', 'editor_theme', 'auto_version'];
2390
+ const allKeys = ['db_path', 'images_dir', 'backup_dir', 'default_context_type', 'editor_theme', 'auto_version'];
1758
2391
  const result = {};
1759
2392
  for (const k of allKeys) result[k] = db.getSetting(k);
1760
2393
  return result;
@@ -1772,7 +2405,11 @@ async function handlePutSettings(req, res) {
1772
2405
  const changedKeys = await withSqliteBusyRetry(() => {
1773
2406
  const keys = [];
1774
2407
  for (const [k, v] of Object.entries(data)) {
1775
- db.setSetting(k, v);
2408
+ if (k === 'backup_dir' && typeof db.setBackupDir === 'function') {
2409
+ db.setBackupDir(v, { persist: true });
2410
+ } else {
2411
+ db.setSetting(k, v);
2412
+ }
1776
2413
  keys.push(k);
1777
2414
  }
1778
2415
  return keys;
@@ -1909,29 +2546,50 @@ function handleHotkeyUninstall(req, res) {
1909
2546
 
1910
2547
  // --- Screenshot (macOS) ---
1911
2548
  async function handleScreenshot(req, res) {
2549
+ // Self-describing diagnostics for the "press hotkey / take screenshot → nothing
2550
+ // happens" reports. Logs one [screenshot] line per request with where the time
2551
+ // went (capture vs DB write), the live session count, and the write outcome —
2552
+ // so a stall can be attributed to a blocked loop, a busy lock, or a cancel.
2553
+ const t0 = Date.now();
2554
+ const { sessions, sessionEvents } = require('./server-state');
2555
+ const sessionCount = sessions ? sessions.size : -1;
2556
+ let captureMs = 0;
2557
+ let writeMs = 0;
1912
2558
  try {
1913
2559
  const { execFile } = require('child_process');
1914
2560
  const tmpFile = path.join(db.DEFAULT_IMAGES_DIR, `screenshot-${Date.now()}.png`);
1915
2561
  // Use async execFile so the event loop stays alive — this lets the browser
1916
2562
  // remain responsive and allows screencapture to work across all monitors.
2563
+ const tCap = Date.now();
1917
2564
  await new Promise((resolve, reject) => {
1918
2565
  execFile('/usr/sbin/screencapture', ['-i', tmpFile], { timeout: 30000 }, (err) => {
1919
2566
  if (err) reject(err); else resolve();
1920
2567
  });
1921
2568
  });
1922
- if (!fs.existsSync(tmpFile)) {
2569
+ captureMs = Date.now() - tCap;
2570
+ try {
2571
+ await fs.promises.access(tmpFile, fs.constants.R_OK);
2572
+ } catch (err) {
2573
+ if (err && err.code !== 'ENOENT') throw err;
2574
+ console.warn(`[screenshot] cancelled — capture=${captureMs}ms sessions=${sessionCount}`);
1923
2575
  return jsonResponse(res, 400, { error: 'Screenshot cancelled' });
1924
2576
  }
1925
- const buffer = fs.readFileSync(tmpFile);
1926
- const result = await db.saveImage(0, buffer, path.basename(tmpFile), 'image/png');
2577
+ const tWrite = Date.now();
2578
+ const result = await db.saveImageFromFile(0, tmpFile, path.basename(tmpFile), 'image/png', { deleteSource: true });
2579
+ writeMs = Date.now() - tWrite;
2580
+ if (result && result._timing) delete result._timing; // internal timing — don't emit/return it (this path has its own [screenshot] log)
1927
2581
  // If triggered by hotkey daemon, notify browser clients to open the editor
1928
2582
  const url = new URL(req.url, 'http://localhost');
1929
2583
  if (!url.searchParams.get('token')) {
1930
- const { sessionEvents } = require('./server-state');
1931
2584
  sessionEvents.emit('screenshot-captured', result);
1932
2585
  }
2586
+ console.warn(`[screenshot] ok id=${result.id} total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount}`);
1933
2587
  jsonResponse(res, 201, result);
1934
- } catch (e) { jsonResponse(res, 500, { error: e.message }); }
2588
+ } catch (e) {
2589
+ const busy = /SQLITE_BUSY|write lock|database is locked/i.test(e && e.message || '');
2590
+ console.warn(`[screenshot] FAILED total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount} busy=${busy} err=${e && e.message}`);
2591
+ jsonResponse(res, 500, { error: e.message });
2592
+ }
1935
2593
  }
1936
2594
 
1937
2595
  // --- Backups ---
@@ -1940,7 +2598,19 @@ function handleListBackups(req, res) {
1940
2598
  const dbPath = db.getDbPath();
1941
2599
  let dbSize = 0;
1942
2600
  try { dbSize = require('fs').statSync(dbPath).size; } catch {}
1943
- jsonResponse(res, 200, { backups, db_path: dbPath, backup_dir: require('path').join(require('path').dirname(dbPath), 'backups'), db_size: dbSize });
2601
+ const backupInfo = typeof db.getBackupDirInfo === 'function'
2602
+ ? db.getBackupDirInfo()
2603
+ : { backup_dir: db.getBackupDir() };
2604
+ jsonResponse(res, 200, {
2605
+ backups,
2606
+ db_path: dbPath,
2607
+ data_dir: dbPath ? path.dirname(dbPath) : '',
2608
+ backup_dir: backupInfo.backup_dir || db.getBackupDir(),
2609
+ default_backup_dir: backupInfo.default_backup_dir || '',
2610
+ configured_backup_dir: backupInfo.configured_backup_dir || '',
2611
+ backup_dir_source: backupInfo.source || 'default',
2612
+ db_size: dbSize,
2613
+ });
1944
2614
  }
1945
2615
 
1946
2616
  async function handleCreateBackup(req, res) {
@@ -1972,80 +2642,192 @@ function handleDeleteBackup(req, res, url) {
1972
2642
  jsonResponse(res, 200, { ok: true });
1973
2643
  }
1974
2644
 
1975
- // ============================================================
1976
- // Tool Permissions (reads/writes .claude/settings.local.json)
1977
- // ============================================================
1978
- // Claude Code reads pre-authorized permissions from:
1979
- // Global: ~/.claude/settings.local.json -> permissions.allow[]
1980
- // Per-project: <project>/.claude/settings.local.json -> permissions.allow[]
1981
- // Legacy: ~/.claude.json -> projects[path].allowedTools[] (session approval history, read-only)
2645
+ function _ctmStorageInfo() {
2646
+ const dbPath = db.getDbPath();
2647
+ const backupInfo = typeof db.getBackupDirInfo === 'function'
2648
+ ? db.getBackupDirInfo()
2649
+ : { backup_dir: db.getBackupDir() };
2650
+ let dbSize = 0;
2651
+ try { dbSize = require('fs').statSync(dbPath).size; } catch {}
2652
+ return {
2653
+ service: 'ctm',
2654
+ label: 'CTM',
2655
+ db_path: dbPath,
2656
+ data_dir: dbPath ? path.dirname(dbPath) : '',
2657
+ db_size: dbSize,
2658
+ backup_dir: backupInfo.backup_dir || db.getBackupDir(),
2659
+ default_backup_dir: backupInfo.default_backup_dir || '',
2660
+ configured_backup_dir: backupInfo.configured_backup_dir || '',
2661
+ backup_dir_source: backupInfo.source || 'default',
2662
+ };
2663
+ }
1982
2664
 
1983
- const CLAUDE_JSON_PATH = permissionSync.getClaudeJsonPath();
1984
- const { collectJsonRules, readJsonFile } = permissionSync;
2665
+ async function _requestWalleData(pathname, opts = {}, timeoutMs = 5000) {
2666
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
2667
+ let timer = null;
2668
+ if (controller) timer = setTimeout(() => controller.abort(), timeoutMs);
2669
+ try {
2670
+ const upstream = await walleClient.requestJson(pathname, { ...opts, signal: controller ? controller.signal : null });
2671
+ if (upstream.status < 200 || upstream.status >= 300 || upstream.json?.error) {
2672
+ const err = new Error(upstream.json?.error || upstream.body || `Wall-E returned ${upstream.status}`);
2673
+ err.status = upstream.status;
2674
+ throw err;
2675
+ }
2676
+ return upstream.json?.data || upstream.json || {};
2677
+ } finally {
2678
+ if (timer) clearTimeout(timer);
2679
+ }
2680
+ }
1985
2681
 
1986
- function importPermissionsToDb(options) {
1987
- return permissionSync.importPermissionsToDb(db, options);
2682
+ async function _storageLocations() {
2683
+ const ctm = _ctmStorageInfo();
2684
+ let walle = null;
2685
+ let walleError = '';
2686
+ try {
2687
+ walle = await _requestWalleData('/api/wall-e/storage', {}, 5000);
2688
+ walle.service = 'walle';
2689
+ walle.label = 'Wall-E';
2690
+ } catch (e) {
2691
+ walleError = e.message || String(e);
2692
+ }
2693
+ return { ctm, walle, walle_error: walleError };
1988
2694
  }
1989
2695
 
1990
- function syncDbToJsonFiles(options) {
1991
- return permissionSync.syncDbToJsonFiles(db, options);
2696
+ async function handleGetStorageLocations(req, res) {
2697
+ try {
2698
+ const locations = await _storageLocations();
2699
+ jsonResponse(res, 200, { ok: true, ...locations, migration: storageMigration.readActiveMigration() });
2700
+ } catch (e) {
2701
+ jsonResponse(res, 500, { error: e.message });
2702
+ }
1992
2703
  }
1993
2704
 
1994
- function handleGetProjects(req, res) {
1995
- const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
1996
- const projects = claudeJson.projects || {};
1997
- const dbRules = db.listPermRules({ listType: 'allow' });
1998
- const result = Object.entries(projects).map(([projectPath]) => {
1999
- const allowedTools = dbRules.filter(r => r.project === projectPath).map(r => r.rule);
2000
- return { path: projectPath, allowedTools };
2705
+ async function handlePutBackupDirs(req, res) {
2706
+ try {
2707
+ const data = await readBody(req, 64 * 1024);
2708
+ const result = await _applyBackupDirChanges(data);
2709
+ jsonResponse(res, result.ok ? 200 : 207, result);
2710
+ } catch (e) {
2711
+ jsonResponse(res, 400, { error: e.message });
2712
+ }
2713
+ }
2714
+
2715
+ async function _applyBackupDirChanges(data = {}) {
2716
+ const moveExisting = data.move_existing !== false && data.moveExisting !== false;
2717
+ const result = { ok: true, ctm: null, walle: null, errors: [] };
2718
+ if (Object.prototype.hasOwnProperty.call(data, 'ctm_backup_dir')) {
2719
+ result.ctm = db.setBackupDir(data.ctm_backup_dir || '', { persist: true, moveExisting });
2720
+ }
2721
+ if (Object.prototype.hasOwnProperty.call(data, 'walle_backup_dir')) {
2722
+ try {
2723
+ result.walle = await _requestWalleData('/api/wall-e/storage/backup-dir', {
2724
+ method: 'PUT',
2725
+ body: { backup_dir: data.walle_backup_dir || '', move_existing: moveExisting },
2726
+ }, 10000);
2727
+ } catch (e) {
2728
+ result.ok = false;
2729
+ result.errors.push({ service: 'walle', error: e.message || String(e) });
2730
+ }
2731
+ }
2732
+ return result;
2733
+ }
2734
+
2735
+ async function _buildPlanFromRequest(data) {
2736
+ const locations = await _storageLocations();
2737
+ return storageMigration.buildStorageMigrationPlan(data || {}, {
2738
+ dbModule: db,
2739
+ ctmCurrent: locations.ctm,
2740
+ walleCurrent: locations.walle || {
2741
+ data_dir: '',
2742
+ db_path: '',
2743
+ backup_dir: '',
2744
+ },
2001
2745
  });
2002
- jsonResponse(res, 200, result);
2003
2746
  }
2004
2747
 
2005
- function handleGetToolPermRules(req, res) {
2006
- // Merge any new rules Claude Code may have added to JSON files
2007
- for (const r of collectJsonRules()) {
2008
- db.addPermRule(r); // INSERT OR IGNORE
2748
+ async function handlePreviewStorageMigration(req, res) {
2749
+ try {
2750
+ const data = await readBody(req, 128 * 1024);
2751
+ const plan = await _buildPlanFromRequest(data);
2752
+ jsonResponse(res, 200, { ok: true, plan });
2753
+ } catch (e) {
2754
+ jsonResponse(res, 400, { error: e.message });
2009
2755
  }
2010
- // Read from SQLite (source of truth)
2011
- const allRules = db.listPermRules();
2756
+ }
2012
2757
 
2758
+ async function handleApplyStorageMigration(req, res) {
2759
+ try {
2760
+ const data = await readBody(req, 128 * 1024);
2761
+ if (data.confirm !== true) return jsonResponse(res, 400, { error: 'confirmation_required' });
2762
+ const plan = await _buildPlanFromRequest(data);
2763
+ if (!plan.requires_restart) {
2764
+ const services = data.services || {};
2765
+ const backupInput = {
2766
+ move_existing: services.ctm?.move_backups !== false && services.walle?.move_backups !== false,
2767
+ };
2768
+ if (services.ctm && (Object.prototype.hasOwnProperty.call(services.ctm, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.ctm, 'backupDir'))) {
2769
+ backupInput.ctm_backup_dir = services.ctm.backup_dir || services.ctm.backupDir || '';
2770
+ }
2771
+ if (services.walle && (Object.prototype.hasOwnProperty.call(services.walle, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.walle, 'backupDir'))) {
2772
+ backupInput.walle_backup_dir = services.walle.backup_dir || services.walle.backupDir || '';
2773
+ }
2774
+ const backup = await _applyBackupDirChanges(backupInput);
2775
+ return jsonResponse(res, backup.ok ? 200 : 207, { ok: backup.ok, plan, backup });
2776
+ }
2777
+ const started = storageMigration.spawnStorageMigrationSupervisor(plan);
2778
+ jsonResponse(res, 202, { ok: true, plan, migration: started });
2779
+ } catch (e) {
2780
+ jsonResponse(res, 400, { error: e.message });
2781
+ }
2782
+ }
2783
+
2784
+ // ============================================================
2785
+ // Tool Permissions — CTM's standalone, global permission config.
2786
+ // ============================================================
2787
+ // CTM permissions live ONLY in the CTM DB (perm_rules table) and are global
2788
+ // (apply to all projects/sessions). They are intentionally decoupled from
2789
+ // Claude Code / Codex settings files — CTM no longer reads or writes
2790
+ // ~/.claude/settings*.json, project .claude/settings.json, or ~/.claude.json.
2791
+ // The shadow approver (approval-agent.js + lib/permission-match.js) enforces them.
2792
+
2793
+ function handleGetProjects(req, res) {
2794
+ // CTM permissions are global — no per-project scoping, no Claude config read.
2795
+ jsonResponse(res, 200, []);
2796
+ }
2797
+
2798
+ async function handleGetToolPermRules(req, res) {
2799
+ // CTM permissions are a standalone GLOBAL config stored only in the CTM DB —
2800
+ // no reading from Claude/Codex settings files. Every rule is global.
2801
+ const allRules = db.listPermRules();
2013
2802
  const rules = [];
2014
2803
  const denyRules = [];
2015
- const projectSet = new Set();
2016
-
2017
2804
  for (const r of allRules) {
2018
- const entry = { scope: r.scope, project: r.project, rule: r.rule };
2019
- if (r.list_type === 'allow') rules.push(entry);
2020
- else denyRules.push(entry);
2021
- if (r.project !== '__global__') projectSet.add(r.project);
2022
- }
2023
-
2024
- // Also include projects from ~/.claude.json that might not have rules
2025
- const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
2026
- for (const p of Object.keys(claudeJson.projects || {})) {
2027
- projectSet.add(p);
2805
+ const entry = { scope: 'global', project: '__global__', rule: r.rule };
2806
+ if (r.list_type === 'deny') denyRules.push(entry);
2807
+ else rules.push(entry);
2028
2808
  }
2029
-
2030
- jsonResponse(res, 200, { rules, denyRules, projects: Array.from(projectSet) });
2809
+ jsonResponse(res, 200, { rules, denyRules, projects: [] });
2031
2810
  }
2032
2811
 
2033
2812
  async function handleSetToolPermRules(req, res) {
2034
2813
  try {
2035
2814
  const body = await readBody(req);
2036
- const { action, rule, project, listType } = body;
2815
+ const { action, rule, listType } = body;
2037
2816
  const lt = listType === 'deny' ? 'deny' : 'allow';
2038
- const proj = project || '__global__';
2039
- const scope = proj === '__global__' ? 'global' : 'project';
2040
2817
 
2041
2818
  if (action === 'add') {
2042
- db.addPermRule({ rule, listType: lt, scope, project: proj });
2819
+ // Permission policy: rules apply globally to ALL projects. Every add is
2820
+ // stored as a global rule (~/.claude/settings.json) regardless of the UI
2821
+ // scope, so a permission granted once is honored everywhere.
2822
+ db.addPermRule({ rule, listType: lt, scope: 'global', project: '__global__' });
2043
2823
  } else if (action === 'remove') {
2044
- db.removePermRule({ rule, listType: lt, project: proj });
2824
+ // Rules are global; remove the global row (legacy per-project rows are
2825
+ // collapsed to global by the db migration).
2826
+ db.removePermRule({ rule, listType: lt, project: '__global__' });
2045
2827
  }
2046
2828
 
2047
- // Sync DB JSON files
2048
- syncDbToJsonFiles();
2829
+ // CTM permissions are a standalone config in the CTM DB intentionally NOT
2830
+ // synced to Claude/Codex settings files. The shadow approver enforces them.
2049
2831
 
2050
2832
  jsonResponse(res, 200, { ok: true });
2051
2833
  } catch (e) {
@@ -2582,20 +3364,21 @@ function handleListQueues(req, res) {
2582
3364
  async function handleCreateQueue(req, res) {
2583
3365
  try {
2584
3366
  const body = await readBody(req);
2585
- const { sessionId, mode, items, idleTimeoutMs, autoStart } = body;
3367
+ const { sessionId, mode, items, idleTimeoutMs, autoStart, append, strategy } = body;
2586
3368
  if (!sessionId || !Array.isArray(items) || items.length === 0) {
2587
3369
  return jsonResponse(res, 400, { error: 'sessionId and non-empty items[] required' });
2588
3370
  }
2589
- let state = queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
2590
- if (autoStart) {
2591
- state = queueEngine.start(sessionId) || state;
2592
- }
3371
+ const appendMode = append === true || strategy === 'append';
3372
+ let state = appendMode
3373
+ ? queueEngine.appendItems(sessionId, { mode, items, idleTimeoutMs, autoStart })
3374
+ : queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
3375
+ if (!appendMode && autoStart) state = queueEngine.start(sessionId) || state;
2593
3376
  jsonResponse(res, 201, state);
2594
3377
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2595
3378
  }
2596
3379
 
2597
3380
  function handleGetQueue(req, res, sessionId) {
2598
- const state = queueEngine.getState(sessionId);
3381
+ const state = queueEngine.getState(sessionId) || queueEngine.getPersistedState(sessionId);
2599
3382
  if (!state) {
2600
3383
  res.writeHead(204);
2601
3384
  res.end();
@@ -2615,8 +3398,23 @@ async function handleQueueAction(req, res, sessionId, action) {
2615
3398
  case 'start': state = queueEngine.start(sessionId); break;
2616
3399
  case 'pause': state = queueEngine.pause(sessionId); break;
2617
3400
  case 'resume': state = queueEngine.resume(sessionId); break;
2618
- case 'next': state = queueEngine.sendNext(sessionId); break;
3401
+ case 'next': state = queueEngine.wake(sessionId, 'manual-next'); break;
2619
3402
  case 'skip': state = queueEngine.skip(sessionId); break;
3403
+ case 'remove': {
3404
+ try {
3405
+ const body = await readBody(req);
3406
+ state = queueEngine.removeItem(sessionId, String(body.itemId || body.id || ''));
3407
+ } catch (e) { return jsonResponse(res, 400, { error: e.message }); }
3408
+ break;
3409
+ }
3410
+ case 'reorder': {
3411
+ try {
3412
+ const body = await readBody(req);
3413
+ const ids = Array.isArray(body.order) ? body.order : (Array.isArray(body.ids) ? body.ids : []);
3414
+ state = queueEngine.reorderItems(sessionId, ids.map(String));
3415
+ } catch (e) { return jsonResponse(res, 400, { error: e.message }); }
3416
+ break;
3417
+ }
2620
3418
  case 'stop': state = queueEngine.stop(sessionId); break;
2621
3419
  case 'mode': {
2622
3420
  try {
@@ -2626,7 +3424,13 @@ async function handleQueueAction(req, res, sessionId, action) {
2626
3424
  break;
2627
3425
  }
2628
3426
  }
2629
- if (!state) return jsonResponse(res, 404, { error: 'No queue for this session' });
3427
+ if (!state) {
3428
+ const persisted = queueEngine.getPersistedState(sessionId);
3429
+ if (action === 'next' && persisted && persisted.status === 'done') {
3430
+ return jsonResponse(res, 200, persisted);
3431
+ }
3432
+ return jsonResponse(res, 404, { error: 'No queue for this session' });
3433
+ }
2630
3434
  jsonResponse(res, 200, state);
2631
3435
  }
2632
3436
 
@@ -2664,6 +3468,35 @@ function handleDeleteQueueLinkedItems(req, res, promptId) {
2664
3468
 
2665
3469
  // --- Queue Draft (per-session builder state persisted to DB) ---
2666
3470
 
3471
+ function queueDraftStatusTimestamp(value) {
3472
+ const n = Number(value);
3473
+ return Number.isFinite(n) && n > 0 ? n : 0;
3474
+ }
3475
+
3476
+ function mergeQueueDraftItemsByStatusTimestamp(existingItems, incomingItems) {
3477
+ const incoming = Array.isArray(incomingItems) ? incomingItems : [];
3478
+ const existing = Array.isArray(existingItems) ? existingItems : [];
3479
+ const existingById = new Map();
3480
+ for (const item of existing) {
3481
+ if (item && item.id) existingById.set(item.id, item);
3482
+ }
3483
+ return incoming.map((item) => {
3484
+ if (!item || typeof item !== 'object') return item;
3485
+ const previous = item.id ? existingById.get(item.id) : null;
3486
+ if (!previous) return item;
3487
+ const previousStatusAt = queueDraftStatusTimestamp(previous.statusUpdatedAt);
3488
+ const incomingStatusAt = queueDraftStatusTimestamp(item.statusUpdatedAt);
3489
+ if (previousStatusAt > incomingStatusAt && previous.status) {
3490
+ return {
3491
+ ...item,
3492
+ status: previous.status,
3493
+ statusUpdatedAt: previous.statusUpdatedAt,
3494
+ };
3495
+ }
3496
+ return item;
3497
+ });
3498
+ }
3499
+
2667
3500
  function handleGetQueueDraft(req, res, sessionId) {
2668
3501
  const key = 'queue_draft_' + sessionId;
2669
3502
  const draft = db.getSetting(key, { items: [], mode: 'manual' });
@@ -2675,7 +3508,10 @@ async function handleSaveQueueDraft(req, res, sessionId) {
2675
3508
  const body = await readBody(req);
2676
3509
  const key = 'queue_draft_' + sessionId;
2677
3510
  const existing = db.getSetting(key, { items: [], mode: 'manual' });
2678
- if (body.items !== undefined) existing.items = body.items;
3511
+ if (body.items !== undefined) {
3512
+ existing.items = mergeQueueDraftItemsByStatusTimestamp(existing.items, body.items);
3513
+ if (body.savedAt !== undefined) existing.savedAt = body.savedAt;
3514
+ }
2679
3515
  if (body.mode !== undefined) existing.mode = body.mode;
2680
3516
  db.setSetting(key, existing);
2681
3517
  jsonResponse(res, 200, { ok: true });
@@ -2697,6 +3533,94 @@ function handleListApprovalRules(req, res) {
2697
3533
  });
2698
3534
  }
2699
3535
 
3536
+ // Grouping key for an escalation row: prefer the recorded signature; for legacy
3537
+ // rows (recorded before signatures were stored) derive it from the captured
3538
+ // context via the same helper the approver uses, so old + new rows group together.
3539
+ function _escalationKeyFn(row) {
3540
+ if (row && row.command_signature) return row.command_signature;
3541
+ try {
3542
+ return approvalAgent.escalationCommandParts({
3543
+ toolName: row && row.tool_name,
3544
+ command: '',
3545
+ fullContext: row && row.full_context,
3546
+ }).signature;
3547
+ } catch { return ''; }
3548
+ }
3549
+
3550
+ // GET /api/approval-escalations — escalated commands grouped by TYPE for the
3551
+ // Permission "Needs Review" surface (collapses a huge pile into a few reviewable
3552
+ // types, secrets masked).
3553
+ function handleListEscalations(req, res) {
3554
+ try {
3555
+ const rows = db.getPendingEscalations() || [];
3556
+ const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
3557
+ jsonResponse(res, 200, { groups, total: rows.length, typeCount: groups.length });
3558
+ } catch (e) {
3559
+ console.error('[escalations] list error:', e.message);
3560
+ jsonResponse(res, 500, { error: e.message });
3561
+ }
3562
+ }
3563
+
3564
+ // POST /api/approval-escalations/resolve — resolve a whole TYPE at once.
3565
+ // body: { action: 'whitelist'|'block'|'dismiss'|'dismiss-all', ids:[...], rule? }
3566
+ // whitelist/block create an authoritative perm_rules allow/deny (honored by the
3567
+ // approver + shown in the rule list); all matching escalated rows are cleared.
3568
+ async function handleResolveEscalations(req, res) {
3569
+ try {
3570
+ const body = await readBody(req);
3571
+ const action = String(body.action || '').toLowerCase();
3572
+ const rule = String(body.rule || '').trim();
3573
+ if (!['whitelist', 'block', 'dismiss', 'dismiss-all', 'approve-all'].includes(action)) {
3574
+ return jsonResponse(res, 400, { error: 'invalid_action' });
3575
+ }
3576
+ // Bulk approve: whitelist EVERY pending type (one allow rule per group from its
3577
+ // suggested narrow pattern) and clear the queue. Re-group server-side so the
3578
+ // action is atomic and can't be skewed by a stale client view.
3579
+ if (action === 'approve-all') {
3580
+ const rows = db.getPendingEscalations() || [];
3581
+ const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
3582
+ let resolved = 0;
3583
+ let rulesCreated = 0;
3584
+ for (const g of groups) {
3585
+ const r = String(g.suggestedRule || '').trim();
3586
+ if (/^[A-Za-z]+\([^)]*\)$/.test(r)) {
3587
+ try {
3588
+ db.addPermRule({ rule: r, listType: 'allow', scope: 'global', project: '__global__' });
3589
+ rulesCreated += 1;
3590
+ } catch (e) { /* rule may already exist — still clear the rows below */ }
3591
+ }
3592
+ for (const id of (g.ids || [])) {
3593
+ try { db.resolveApprovalDecision(id, 'approved'); resolved += 1; } catch (e) { /* skip */ }
3594
+ }
3595
+ }
3596
+ return jsonResponse(res, 200, { ok: true, action, resolved, rulesCreated, typeCount: groups.length });
3597
+ }
3598
+ let createdRule = null;
3599
+ if (action === 'whitelist' || action === 'block') {
3600
+ if (!/^[A-Za-z]+\([^)]*\)$/.test(rule)) {
3601
+ return jsonResponse(res, 400, { error: 'rule must look like Bash(cmd:*)' });
3602
+ }
3603
+ db.addPermRule({ rule, listType: action === 'whitelist' ? 'allow' : 'deny', scope: 'global', project: '__global__' });
3604
+ createdRule = rule;
3605
+ }
3606
+ let targetIds;
3607
+ if (action === 'dismiss-all') {
3608
+ targetIds = (db.getPendingEscalations() || []).map((r) => r.id);
3609
+ } else {
3610
+ targetIds = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter(Number.isFinite);
3611
+ if (!targetIds.length) return jsonResponse(res, 400, { error: 'ids_required' });
3612
+ }
3613
+ const resolveDecision = action === 'block' ? 'denied' : action === 'whitelist' ? 'approved' : 'dismissed';
3614
+ let resolved = 0;
3615
+ for (const id of targetIds) {
3616
+ try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; } catch (e) { /* skip */ }
3617
+ }
3618
+ jsonResponse(res, 200, { ok: true, action, resolved, rule: createdRule });
3619
+ } catch (e) {
3620
+ jsonResponse(res, 400, { error: e.message });
3621
+ }
3622
+ }
3623
+
2700
3624
  async function handleUpsertApprovalRule(req, res) {
2701
3625
  try {
2702
3626
  const body = await readBody(req);
@@ -2750,23 +3674,84 @@ async function handleResolveApprovalDecision(req, res, id) {
2750
3674
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2751
3675
  }
2752
3676
 
2753
- // --- Dangerous-command blocklist (opt-in) ---
2754
- // GET /api/approval/blocklist -> { enabled: bool, patterns: [...] }
2755
- // POST /api/approval/blocklist { enabled: bool } -> { enabled }
3677
+ // --- Dangerous-command blocklist (configurable; default ON) ---
3678
+ // GET /api/approval/blocklist
3679
+ // -> { enabled, patterns: [defaults], disabledIds: [int], custom: [{source,flags,reason,category,id}] }
3680
+ // POST /api/approval/blocklist { enabled?, disabledIds?, customPatterns? }
3681
+ // -> the same shape as GET (the merged view). Any field omitted is left as-is.
3682
+ // Custom patterns are validated server-side; an invalid one returns 400 and
3683
+ // nothing is persisted. The hard floor is editable but never silently broken.
3684
+ //
3685
+ // Persistence: `approval_blocklist_enabled` (bool) + `approval_blocklist_config`
3686
+ // ({ disabledIds:[int], customPatterns:[{source,flags,reason,category}] }).
3687
+ // The agent reads both on every check (approval-agent isBlocklistEnabled /
3688
+ // getBlocklistConfig), so edits take effect without a restart.
3689
+ function _blocklistView() {
3690
+ const { PATTERN_META } = require('./workers/approval-blocklist');
3691
+ const enabled = db.getSetting('approval_blocklist_enabled', true) !== false;
3692
+ const cfg = db.getSetting('approval_blocklist_config', null) || {};
3693
+ const disabledIds = Array.isArray(cfg.disabledIds)
3694
+ ? cfg.disabledIds.filter((n) => Number.isInteger(n) && n >= 0 && n < PATTERN_META.length)
3695
+ : [];
3696
+ const custom = Array.isArray(cfg.customPatterns)
3697
+ ? cfg.customPatterns.map((p, i) => ({
3698
+ id: (p && p.id != null) ? p.id : `c${i}`,
3699
+ source: String((p && p.source) || ''),
3700
+ flags: String((p && p.flags) || ''),
3701
+ reason: String((p && p.reason) || 'Custom blocklist pattern'),
3702
+ category: String((p && p.category) || 'custom'),
3703
+ }))
3704
+ : [];
3705
+ return { enabled, patterns: PATTERN_META, disabledIds, custom };
3706
+ }
2756
3707
  function handleGetBlocklist(_req, res) {
2757
3708
  try {
2758
- const { PATTERN_META } = require('./workers/approval-blocklist');
2759
- const enabled = !!db.getSetting('approval_blocklist_enabled', false);
2760
- jsonResponse(res, 200, { enabled, patterns: PATTERN_META });
3709
+ jsonResponse(res, 200, _blocklistView());
2761
3710
  } catch (e) { jsonResponse(res, 500, { error: e.message }); }
2762
3711
  }
2763
3712
  async function handleSetBlocklistEnabled(req, res) {
2764
3713
  try {
2765
- const body = await readBody(req);
2766
- const enabled = !!body.enabled;
2767
- db.setSetting('approval_blocklist_enabled', enabled);
2768
- console.log(`[approval-blocklist] enabled=${enabled}`);
2769
- jsonResponse(res, 200, { enabled });
3714
+ const { validateUserPattern, PATTERN_META } = require('./workers/approval-blocklist');
3715
+ const body = await readBody(req) || {};
3716
+
3717
+ if (Object.prototype.hasOwnProperty.call(body, 'enabled')) {
3718
+ db.setSetting('approval_blocklist_enabled', !!body.enabled);
3719
+ console.log(`[approval-blocklist] enabled=${!!body.enabled}`);
3720
+ }
3721
+
3722
+ const touchesConfig = Object.prototype.hasOwnProperty.call(body, 'disabledIds')
3723
+ || Object.prototype.hasOwnProperty.call(body, 'customPatterns');
3724
+ if (touchesConfig) {
3725
+ const cfg = db.getSetting('approval_blocklist_config', null) || {};
3726
+
3727
+ if (Object.prototype.hasOwnProperty.call(body, 'disabledIds')) {
3728
+ if (!Array.isArray(body.disabledIds)) return jsonResponse(res, 400, { error: 'disabledIds must be an array of pattern ids' });
3729
+ const ids = [];
3730
+ for (const raw of body.disabledIds) {
3731
+ const n = Number(raw);
3732
+ if (!Number.isInteger(n) || n < 0 || n >= PATTERN_META.length) return jsonResponse(res, 400, { error: `invalid pattern id: ${raw}` });
3733
+ if (!ids.includes(n)) ids.push(n);
3734
+ }
3735
+ cfg.disabledIds = ids;
3736
+ }
3737
+
3738
+ if (Object.prototype.hasOwnProperty.call(body, 'customPatterns')) {
3739
+ if (!Array.isArray(body.customPatterns)) return jsonResponse(res, 400, { error: 'customPatterns must be an array' });
3740
+ if (body.customPatterns.length > 200) return jsonResponse(res, 400, { error: 'too many custom patterns (max 200)' });
3741
+ const normalized = [];
3742
+ for (let i = 0; i < body.customPatterns.length; i++) {
3743
+ const v = validateUserPattern(body.customPatterns[i]);
3744
+ if (!v.ok) return jsonResponse(res, 400, { error: `pattern ${i + 1}: ${v.error}` });
3745
+ normalized.push(v.normalized); // { source, flags, reason, category }
3746
+ }
3747
+ cfg.customPatterns = normalized;
3748
+ }
3749
+
3750
+ db.setSetting('approval_blocklist_config', cfg);
3751
+ console.log(`[approval-blocklist] config updated: ${(cfg.disabledIds || []).length} disabled, ${(cfg.customPatterns || []).length} custom`);
3752
+ }
3753
+
3754
+ jsonResponse(res, 200, _blocklistView());
2770
3755
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2771
3756
  }
2772
3757
 
@@ -3049,22 +4034,53 @@ function handlePromptQuality(req, res, url) {
3049
4034
  jsonResponse(res, 200, harvest.assessPromptQuality(text));
3050
4035
  }
3051
4036
 
3052
- function handleListExecutions(req, res, url) {
4037
+ // Injected by server.js: run the list query on the read-pool (off the main loop)
4038
+ // and resolve to the executions array, or null when the pool is unavailable so we
4039
+ // fall back to the main-thread query. SELECT * with a large LIMIT blocked the loop
4040
+ // ~3.9s on the request path per a CPU profile.
4041
+ let _promptExecutionsOffThread = null;
4042
+ function setPromptExecutionsOffThread(fn) { _promptExecutionsOffThread = typeof fn === 'function' ? fn : null; }
4043
+
4044
+ async function handleListExecutions(req, res, url) {
4045
+ const opts = {
4046
+ limit: url.searchParams.get('limit'),
4047
+ role: url.searchParams.get('role') || null,
4048
+ project: url.searchParams.get('project') || null,
4049
+ after: url.searchParams.get('after') || url.searchParams.get('since') || null,
4050
+ order: url.searchParams.get('order') || 'desc',
4051
+ };
3053
4052
  try {
3054
- const d = db.getDb();
3055
- const limit = parseInt(url.searchParams.get('limit')) || 50;
3056
- const role = url.searchParams.get('role') || null;
3057
- const project = url.searchParams.get('project') || null;
3058
- let sql = 'SELECT * FROM prompt_executions WHERE 1=1';
3059
- const params = [];
3060
- if (role) { sql += ' AND role = ?'; params.push(role); }
3061
- if (project) { sql += ' AND project_path LIKE ?'; params.push('%' + project + '%'); }
3062
- sql += ' ORDER BY executed_at DESC LIMIT ?';
3063
- params.push(limit);
3064
- jsonResponse(res, 200, { executions: d.prepare(sql).all(...params) });
4053
+ if (_promptExecutionsOffThread) {
4054
+ const executions = await _promptExecutionsOffThread(opts);
4055
+ if (executions) return jsonResponse(res, 200, { executions });
4056
+ // null pool unavailable; fall through to the main-thread query.
4057
+ }
4058
+ jsonResponse(res, 200, { executions: queryPromptExecutions(db.getDb(), opts) });
3065
4059
  } catch (e) { jsonResponse(res, 200, { executions: [] }); }
3066
4060
  }
3067
4061
 
4062
+ function handlePromptExecutionStats(req, res) {
4063
+ try {
4064
+ const d = db.getDb();
4065
+ const totalSessions = d.prepare('SELECT COUNT(DISTINCT session_id) AS count FROM prompt_executions').get().count;
4066
+ const totalPairs = d.prepare(`
4067
+ SELECT COUNT(*) AS count FROM prompt_executions u
4068
+ JOIN prompt_executions a ON a.session_id = u.session_id AND a.message_index = u.message_index + 1
4069
+ WHERE u.role = 'user' AND a.role = 'assistant'
4070
+ AND length(u.message_text) >= 20 AND length(a.message_text) >= 20
4071
+ `).get().count;
4072
+ const multiTurnSessions = d.prepare(`
4073
+ SELECT COUNT(*) AS count FROM (
4074
+ SELECT session_id FROM prompt_executions WHERE role = 'user'
4075
+ GROUP BY session_id HAVING COUNT(*) >= 2
4076
+ )
4077
+ `).get().count;
4078
+ jsonResponse(res, 200, { totalSessions, totalPairs, multiTurnSessions });
4079
+ } catch (e) {
4080
+ jsonResponse(res, 200, { totalSessions: 0, totalPairs: 0, multiTurnSessions: 0, error: e.message });
4081
+ }
4082
+ }
4083
+
3068
4084
  function handleSessionExecutions(req, res, sessionId) {
3069
4085
  try {
3070
4086
  const result = harvest.getPromptsForSession(sessionId);
@@ -3203,30 +4219,28 @@ async function handleImprovePrompt(req, res) {
3203
4219
 
3204
4220
  function handleSkillAutocomplete(req, res, url) {
3205
4221
  try {
3206
- const query = (url.searchParams.get('q') || '').toLowerCase();
4222
+ const query = url.searchParams.get('q') || '';
3207
4223
  const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
3208
4224
  const cwd = url.searchParams.get('cwd') || '';
3209
4225
  const includeSessionSeen = !agent || agent === 'all' || url.searchParams.get('includeSession') === '1';
3210
4226
  const skills = buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen });
3211
4227
 
3212
- let filtered = skills;
3213
- if (query) {
3214
- filtered = skills.filter(s => fuzzySkillMatch(s.name, query));
3215
- }
3216
-
3217
- filtered.sort((a, b) => {
3218
- if (b.frequency !== a.frequency) return b.frequency - a.frequency;
3219
- if (agent) {
3220
- const sourceDelta = skillAutocomplete.skillSourcePriorityForAgent(a.source, agent) - skillAutocomplete.skillSourcePriorityForAgent(b.source, agent);
3221
- if (sourceDelta) return sourceDelta;
3222
- }
3223
- return a.name.localeCompare(b.name);
3224
- });
4228
+ const filtered = skillAutocomplete.filterAndSortSkillAutocompleteItems(skills, query, agent || 'all');
3225
4229
 
3226
4230
  jsonResponse(res, 200, filtered.slice(0, 20));
3227
4231
  } catch (e) { jsonResponse(res, 500, { error: e.message }); }
3228
4232
  }
3229
4233
 
4234
+ function handleSkillResolveIntent(req, res, url) {
4235
+ try {
4236
+ const intent = url.searchParams.get('intent') || '';
4237
+ const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
4238
+ const cwd = url.searchParams.get('cwd') || '';
4239
+ const result = skillIntentResolver.resolveSkillIntent({ intent, agent, cwd });
4240
+ jsonResponse(res, result.ok === false ? 400 : 200, result);
4241
+ } catch (e) { jsonResponse(res, 500, { ok: false, error: e.message }); }
4242
+ }
4243
+
3230
4244
  function normalizeSkillAutocompleteAgent(value) {
3231
4245
  return skillAutocomplete.normalizeSkillAgentType(value);
3232
4246
  }
@@ -3244,6 +4258,8 @@ function buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen }) {
3244
4258
  description: skill.description || '',
3245
4259
  source: skill.source || 'skill',
3246
4260
  agents: Array.isArray(skill.agents) ? skill.agents : [],
4261
+ capabilities: Array.isArray(skill.capabilities) ? skill.capabilities : [],
4262
+ invocation: skill.invocation || '',
3247
4263
  execution: skill.execution || '',
3248
4264
  path: skill.filePath || skill.path || '',
3249
4265
  frequency: 0,
@@ -3281,14 +4297,6 @@ function sanitizeSkillAutocompleteName(value) {
3281
4297
  return /^[A-Za-z0-9_.:-]{1,80}$/.test(name) ? name : '';
3282
4298
  }
3283
4299
 
3284
- function fuzzySkillMatch(name, query) {
3285
- let qi = 0;
3286
- for (const ch of String(name || '').toLowerCase()) {
3287
- if (qi < query.length && ch === query[qi]) qi++;
3288
- }
3289
- return qi === query.length;
3290
- }
3291
-
3292
4300
  function handleHarvestStats(req, res) {
3293
4301
  try {
3294
4302
  const state = harvest.getHarvestState();
@@ -3339,4 +4347,4 @@ function safeParse(json, fallback) {
3339
4347
  try { return JSON.parse(json); } catch { return fallback; }
3340
4348
  }
3341
4349
 
3342
- module.exports = { handlePromptApi, queueEngine, importPermissionsToDb, runIncrementalConversationImport, importSessionFile, setUiPrefsBroadcaster, setDbMaintenanceRunner };
4350
+ module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile };