@cuongtran001/kanna 1.2.0 → 1.3.1

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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -1,260 +1,57 @@
1
1
  import { homedir } from "node:os"
2
2
  import path from "node:path"
3
- import { getDataDir, LOG_PREFIX } from "../shared/branding"
4
- import type { AnyValue } from "../shared/errors"
3
+ import { getDataDir } from "../shared/branding"
5
4
  import { log } from "../shared/log"
6
5
  import type { StorageBackend } from "./storage/backend"
7
6
  import { FsStorageBackend } from "./storage/fs-storage.adapter"
8
- import type { AgentProvider, ChatHistoryPage, ChatHistorySnapshot, QueuedChatMessage, SlashCommand, StackBinding, SubagentRunSnapshot, TranscriptEntry } from "../shared/types"
9
- import { STORE_VERSION } from "../shared/types"
7
+ import type { AgentProvider, ChatHistoryPage, QueuedChatMessage, SlashCommand, StackBinding, SubagentRunSnapshot, TranscriptEntry } from "../shared/types"
10
8
  import type { AutoContinueEvent } from "./auto-continue/events"
11
9
  import {
12
- type ChatEvent,
13
- type ChatRecord,
14
- type ChatTimingState,
15
10
  type OrchestrationEvent,
16
- type OrchRunRecord,
17
- type OrchTaskRecord,
18
- type ProjectEvent,
19
- type QueuedMessageEvent,
20
- type SnapshotFile,
21
- type StackEvent,
22
11
  type StackRecord,
23
12
  type StoreEvent,
24
13
  type StoreState,
25
14
  type SubagentRunEvent,
26
- type ToolRequestEvent,
27
- type TurnEvent,
28
15
  type TurnRunConfig,
29
- cloneTranscriptEntries,
30
16
  createEmptyState,
31
17
  } from "./events"
32
- import type { OrchRunSnapshot, OrchTaskSnapshot } from "../shared/orchestration-types"
33
- import { isTaskEventType, nextRunStatus, nextTaskState } from "./orchestration-state-machine"
18
+ import type { OrchRunSnapshot } from "../shared/orchestration-types"
34
19
  import type { ChatPermissionPolicyOverride, ToolRequest, ToolRequestDecision, ToolRequestStatus } from "../shared/permission-policy"
35
- import { resolveLocalPath } from "./paths"
36
20
  import type { CloudflareTunnelEvent } from "./cloudflare-tunnel/events"
37
21
  import type { PushEvent, PushEventStore } from "./push/events"
38
22
  import type { ShareEvent } from "./session-share/share-projection"
39
- import { ACTIVE_SESSION_IDLE_GAP_MS } from "./read-models"
40
- import { capTranscriptEntry } from "./subagent-entry-cap.adapter"
23
+ import {
24
+ getSubagentRuns as getSubagentRunsFromMap,
25
+ runningSubagentRuns as runningSubagentRunsFromMap,
26
+ appendSubagentEvent as appendSubagentEventFn,
27
+ type AppendSubagentDeps,
28
+ } from "./event-store-subagent"
29
+ import {
30
+ getToolRequest as getToolRequestFromMap,
31
+ listPendingToolRequests as listPendingToolRequestsFromMap,
32
+ scanAllToolRequests as scanAllToolRequestsFromMap,
33
+ putToolRequest as putToolRequestFn,
34
+ resolveToolRequest as resolveToolRequestFn,
35
+ type ToolRequestWriteDeps,
36
+ } from "./event-store-tool-requests"
37
+ import * as OrchSubscription from "./event-store-orch-subscription"
38
+ import { applyStoreEvent } from "./event-store-apply"
39
+ import * as PeripheralEvents from "./event-store-peripheral-events.adapter"
40
+ import * as MessageRead from "./event-store-messages.adapter"
41
+ import * as EntityWrite from "./event-store-entity-write"
42
+ import * as TranscriptWrite from "./event-store-transcript-write.adapter"
43
+ import {
44
+ initializeEventStore,
45
+ getLegacyTranscriptStats as getLegacyTranscriptStatsFn,
46
+ hasLegacyTranscriptData as hasLegacyTranscriptDataFn,
47
+ snapshotAndTruncateLogs as snapshotAndTruncateLogsFn,
48
+ migrateLegacyTranscripts as migrateLegacyTranscriptsFn,
49
+ type EventStoreInitDeps,
50
+ type LegacyTranscriptStats,
51
+ } from "./event-store-init"
41
52
 
42
- const SNAPSHOT_THRESHOLD_BYTES = 2 * 1024 * 1024
43
- const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
44
53
  const SIDEBAR_PROJECT_ORDER_FILE = "sidebar-order.json"
45
54
 
46
- function normalizeSidebarProjectOrder<T>(value: T) {
47
- if (!Array.isArray(value)) {
48
- return []
49
- }
50
-
51
- const seen = new Set<string>()
52
- const projectIds: string[] = []
53
- for (const entry of value) {
54
- if (typeof entry !== "string") continue
55
- const projectId = entry.trim()
56
- if (!projectId || seen.has(projectId)) continue
57
- seen.add(projectId)
58
- projectIds.push(projectId)
59
- }
60
-
61
- return projectIds
62
- }
63
-
64
- function isSendToStartingProfilingEnabled() {
65
- return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
66
- }
67
-
68
- function logSendToStartingProfile(stage: string, details?: Record<string, unknown>) {
69
- if (!isSendToStartingProfilingEnabled()) {
70
- return
71
- }
72
-
73
- log.info("[kanna/send->starting][server]", JSON.stringify({
74
- stage,
75
- ...details,
76
- }))
77
- }
78
-
79
- interface LegacyTranscriptStats {
80
- hasLegacyData: boolean
81
- sources: Array<"snapshot" | "messages_log">
82
- chatCount: number
83
- entryCount: number
84
- }
85
-
86
- interface TranscriptPageResult {
87
- entries: TranscriptEntry[]
88
- hasOlder: boolean
89
- olderCursor: string | null
90
- }
91
-
92
- interface ParsedReplayEvent {
93
- event: StoreEvent
94
- sourceIndex: number
95
- lineIndex: number
96
- }
97
-
98
- function getReplayEventPriority(event: StoreEvent): number {
99
- const discriminator = "type" in event ? event.type : event.kind
100
- switch (discriminator) {
101
- case "project_opened":
102
- case "project_removed":
103
- case "sidebar_project_order_set":
104
- case "project_star_set":
105
- return 0
106
- case "chat_created":
107
- return 1
108
- case "chat_renamed":
109
- case "chat_provider_set":
110
- case "chat_plan_mode_set":
111
- return 2
112
- case "message_appended":
113
- return 3
114
- case "queued_message_enqueued":
115
- case "queued_message_removed":
116
- return 4
117
- case "turn_started":
118
- return 5
119
- case "session_token_set":
120
- case "session_commands_loaded":
121
- return 6
122
- case "pending_fork_session_token_set":
123
- return 6
124
- case "turn_cancelled":
125
- return 7
126
- case "turn_finished":
127
- case "turn_failed":
128
- return 8
129
- case "chat_read_state_set":
130
- case "chat_source_hash_set":
131
- case "chat_policy_override_set":
132
- case "chat_compact_failures_set":
133
- return 9
134
- case "chat_deleted":
135
- case "chat_archived":
136
- case "chat_unarchived":
137
- return 10
138
- case "auto_continue_proposed":
139
- case "auto_continue_accepted":
140
- case "auto_continue_rescheduled":
141
- case "auto_continue_cancelled":
142
- case "auto_continue_fired":
143
- case "loop_armed":
144
- case "loop_disarmed":
145
- return 11
146
- case "stack_added":
147
- case "stack_removed":
148
- case "stack_renamed":
149
- case "stack_project_added":
150
- case "stack_project_removed":
151
- return 0
152
- case "subagent_run_started":
153
- case "subagent_message_delta":
154
- case "subagent_entry_appended":
155
- case "subagent_run_completed":
156
- case "subagent_run_failed":
157
- case "subagent_run_cancelled":
158
- case "subagent_tool_pending":
159
- case "subagent_tool_resolved":
160
- return 5
161
- // tool_request_put shares priority 5 with subagent_* events; sourceIndex
162
- // tie-break orders them (tool-requests has sourceIndex 7, turns has 5).
163
- case "tool_request_put":
164
- return 5
165
- case "tool_request_resolved":
166
- return 6
167
- case "orch_run_created":
168
- case "orch_worktree_provisioned":
169
- case "orch_worktree_init_started":
170
- case "orch_worktree_init_completed":
171
- case "orch_task_claimed":
172
- case "orch_phase_started":
173
- case "orch_phase_completed":
174
- case "orch_gate_opened":
175
- case "orch_gate_resolved":
176
- case "orch_scope_overlap_flagged":
177
- case "orch_config_warning":
178
- case "orch_verify_started":
179
- case "orch_verify_completed":
180
- case "orch_task_committed":
181
- case "orch_task_failed":
182
- case "orch_task_requeued":
183
- case "orch_run_completed":
184
- case "orch_run_cancelled":
185
- return 5
186
- default: {
187
- const _exhaustive: never = discriminator
188
- throw new Error(`Unhandled replay event type: ${String(_exhaustive)}`)
189
- }
190
- }
191
- }
192
-
193
- function encodeHistoryCursor(index: number) {
194
- return `idx:${index}`
195
- }
196
-
197
- function decodeCursor(cursor: string) {
198
- if (cursor.startsWith("idx:")) {
199
- const value = Number.parseInt(cursor.slice("idx:".length), 10)
200
- if (!Number.isInteger(value) || value < 0) {
201
- throw new Error("Invalid history cursor")
202
- }
203
- return value
204
- }
205
-
206
- throw new Error("Invalid history cursor")
207
- }
208
-
209
- function slashCommandsEqual(a: SlashCommand[], b: SlashCommand[]) {
210
- if (a.length !== b.length) return false
211
- for (let i = 0; i < a.length; i += 1) {
212
- const ai = a[i]
213
- const bi = b[i]
214
- if (ai.name !== bi.name || ai.description !== bi.description || ai.argumentHint !== bi.argumentHint) {
215
- return false
216
- }
217
- }
218
- return true
219
- }
220
-
221
- // `context_window_updated` entries are token-readout noise emitted once per
222
- // stream tick — only the latest value matters. A single turn can emit hundreds
223
- // of them, and the bounded live window (getMessagesPageFromEntries) counts each
224
- // 1:1 against the limit, so a flood evicts real turns (e.g. the user_prompt)
225
- // out of the snapshot the client renders. Collapsing each maximal run of
226
- // consecutive cwu entries to its last entry keeps the latest readout while
227
- // freeing window budget for real turns. Applied only on the live-window read
228
- // path (not getMessages), so getLatestContextWindowUsage / full-transcript
229
- // export / importer still observe every persisted cwu.
230
- function coalesceContextWindowUpdates(entries: TranscriptEntry[]): TranscriptEntry[] {
231
- const result: TranscriptEntry[] = []
232
- for (let index = 0; index < entries.length; index += 1) {
233
- const entry = entries[index]!
234
- const next = entries[index + 1]
235
- if (entry.kind === "context_window_updated" && next?.kind === "context_window_updated") {
236
- // Drop this cwu; the last of the run survives.
237
- continue
238
- }
239
- result.push(entry)
240
- }
241
- return result
242
- }
243
-
244
- function getHistorySnapshot(page: TranscriptPageResult, recentLimit: number): ChatHistorySnapshot {
245
- return {
246
- hasOlder: page.hasOlder,
247
- olderCursor: page.olderCursor,
248
- recentLimit,
249
- }
250
- }
251
-
252
- function getForkedChatTitle(title: string) {
253
- const trimmed = title.trim()
254
- if (!trimmed) return "Fork: New Chat"
255
- return trimmed.startsWith("Fork: ") ? trimmed : `Fork: ${trimmed}`
256
- }
257
-
258
55
  export class EventStore implements PushEventStore {
259
56
  readonly dataDir: string
260
57
  readonly state: StoreState = createEmptyState()
@@ -283,9 +80,9 @@ export class EventStore implements PushEventStore {
283
80
  // --resume; on cold-wake the reader starts at byte 0 and would re-emit).
284
81
  private seenMessageIdsByChatId = new Map<string, Set<string>>()
285
82
  private legacySidebarProjectOrder: string[] = []
286
- private sidebarProjectOrder: string[] = []
83
+ private readonly sidebarProjectOrderRef: { value: string[] } = { value: [] }
287
84
  private snapshotHasLegacyMessages = false
288
- private cachedTranscript: { chatId: string; entries: TranscriptEntry[] } | null = null
85
+ private readonly cachedTranscriptRef: MessageRead.CachedTranscriptRef = { value: null }
289
86
  private readonly tunnelEventsByChatId = new Map<string, CloudflareTunnelEvent[]>()
290
87
  private shareEventsAll: ShareEvent[] = []
291
88
  private replayChatProvider = new Map<string, AgentProvider | null>()
@@ -313,786 +110,16 @@ export class EventStore implements PushEventStore {
313
110
  }
314
111
 
315
112
  async initialize() {
316
- await this.storage.mkdir(this.dataDir)
317
- await this.storage.mkdir(this.transcriptsDir)
318
- await this.ensureFile(this.projectsLogPath)
319
- await this.ensureFile(this.chatsLogPath)
320
- await this.ensureFile(this.messagesLogPath)
321
- await this.ensureFile(this.queuedMessagesLogPath)
322
- await this.ensureFile(this.turnsLogPath)
323
- await this.ensureFile(this.schedulesLogPath)
324
- await this.ensureFile(this.tunnelLogPath)
325
- await this.ensureFile(this.sharesLogPath)
326
- await this.ensureFile(this.pushLogPath)
327
- await this.ensureFile(this.stacksLogPath)
328
- await this.ensureFile(this.toolRequestsLogPath)
329
- await this.ensureFile(this.orchLogPath)
330
- await this.loadSnapshot()
331
- await this.replayLogs()
332
- await this.loadTunnelEvents()
333
- await this.loadShareEvents()
334
- await this.loadSidebarProjectOrder()
335
- if (!(await this.hasLegacyTranscriptData()) && await this.shouldSnapshotLogs()) {
336
- await this.snapshotAndTruncateLogs()
337
- }
338
- }
339
-
340
- private async ensureFile(filePath: string) {
341
- if (!(await this.storage.exists(filePath))) {
342
- await this.storage.writeText(filePath, "")
343
- }
344
- }
345
-
346
- private async clearStorage() {
347
- if (this.storageReset) return
348
- this.storageReset = true
349
- this.resetState()
350
- this.clearLegacyTranscriptState()
351
- await Promise.all([
352
- this.storage.writeText(this.snapshotPath, ""),
353
- this.storage.writeText(this.projectsLogPath, ""),
354
- this.storage.writeText(this.chatsLogPath, ""),
355
- this.storage.writeText(this.messagesLogPath, ""),
356
- this.storage.writeText(this.queuedMessagesLogPath, ""),
357
- this.storage.writeText(this.turnsLogPath, ""),
358
- this.storage.writeText(this.schedulesLogPath, ""),
359
- this.storage.writeText(this.tunnelLogPath, ""),
360
- this.storage.writeText(this.sharesLogPath, ""),
361
- this.storage.writeText(this.stacksLogPath, ""),
362
- this.storage.writeText(this.toolRequestsLogPath, ""),
363
- this.storage.writeText(this.orchLogPath, ""),
364
- ])
365
- }
366
-
367
- private async loadSnapshot() {
368
- if (!(await this.storage.exists(this.snapshotPath))) return
369
-
370
- try {
371
- const text = await this.storage.readText(this.snapshotPath)
372
- if (!text.trim()) return
373
- const parsed: SnapshotFile = JSON.parse(text)
374
- if (parsed.v !== STORE_VERSION) {
375
- log.warn(`${LOG_PREFIX} Resetting local chat history for store version ${STORE_VERSION}`)
376
- await this.clearStorage()
377
- return
378
- }
379
- for (const project of parsed.projects) {
380
- this.state.projectsById.set(project.id, { ...project })
381
- this.state.projectIdsByPath.set(project.localPath, project.id)
382
- }
383
- for (const chat of parsed.chats) {
384
- // Access legacy fields from old snapshot data via Reflect.get (avoids `as` casts).
385
- const legacySessionToken: string | null | undefined = Reflect.get(chat, "sessionToken")
386
- const legacyPendingFork: string | null | { provider: AgentProvider; token: string } | undefined = Reflect.get(chat, "pendingForkSessionToken")
387
- const legacyTokensByProvider: Partial<Record<AgentProvider, string | null>> | undefined = Reflect.get(chat, "sessionTokensByProvider")
388
-
389
- const sessionTokensByProvider: Partial<Record<AgentProvider, string | null>> =
390
- legacyTokensByProvider ? { ...legacyTokensByProvider } : {}
391
- if (
392
- typeof legacySessionToken === "string"
393
- && chat.provider
394
- && sessionTokensByProvider[chat.provider] == null
395
- ) {
396
- sessionTokensByProvider[chat.provider] = legacySessionToken
397
- }
398
- let pendingForkSessionToken: ChatRecord["pendingForkSessionToken"] = null
399
- if (legacyPendingFork && typeof legacyPendingFork === "object" && "token" in legacyPendingFork) {
400
- pendingForkSessionToken = legacyPendingFork
401
- } else if (typeof legacyPendingFork === "string" && chat.provider) {
402
- pendingForkSessionToken = { provider: chat.provider, token: legacyPendingFork }
403
- }
404
- this.state.chatsById.set(chat.id, {
405
- ...chat,
406
- unread: chat.unread ?? false,
407
- sessionTokensByProvider,
408
- pendingForkSessionToken,
409
- })
410
- }
411
- this.legacySidebarProjectOrder = normalizeSidebarProjectOrder(parsed.sidebarProjectOrder)
412
- if (parsed.queuedMessages?.length) {
413
- for (const queuedSet of parsed.queuedMessages) {
414
- this.state.queuedMessagesByChatId.set(queuedSet.chatId, queuedSet.entries.map((entry) => ({
415
- ...entry,
416
- attachments: [...entry.attachments],
417
- })))
418
- }
419
- }
420
- if (parsed.messages?.length) {
421
- this.snapshotHasLegacyMessages = true
422
- for (const messageSet of parsed.messages) {
423
- this.legacyMessagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
424
- }
425
- }
426
- if (parsed.autoContinueEvents?.length) {
427
- for (const entry of parsed.autoContinueEvents) {
428
- this.state.autoContinueEventsByChatId.set(entry.chatId, [...entry.events])
429
- }
430
- }
431
- if (parsed.stacks?.length) {
432
- for (const stack of parsed.stacks) {
433
- this.state.stacksById.set(stack.id, { ...stack, projectIds: [...stack.projectIds] })
434
- }
435
- }
436
- } catch (error) {
437
- log.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, String(error))
438
- await this.clearStorage()
439
- }
440
- }
441
-
442
- private resetState() {
443
- this.state.projectsById.clear()
444
- this.state.projectIdsByPath.clear()
445
- this.state.chatsById.clear()
446
- this.state.queuedMessagesByChatId.clear()
447
- this.state.sidebarProjectOrder = []
448
- this.state.autoContinueEventsByChatId.clear()
449
- this.state.stacksById.clear()
450
- this.tunnelEventsByChatId.clear()
451
- this.sidebarProjectOrder = []
452
- this.legacySidebarProjectOrder = []
453
- this.cachedTranscript = null
454
- }
455
-
456
- private clearLegacyTranscriptState() {
457
- this.legacyMessagesByChatId.clear()
458
- this.snapshotHasLegacyMessages = false
459
- }
460
-
461
- private async loadSidebarProjectOrder() {
462
- if (await this.storage.exists(this.sidebarProjectOrderPath)) {
463
- try {
464
- const text = await this.storage.readText(this.sidebarProjectOrderPath)
465
- if (!text.trim()) {
466
- this.sidebarProjectOrder = []
467
- return
468
- }
469
- this.sidebarProjectOrder = normalizeSidebarProjectOrder(JSON.parse(text))
470
- } catch (error) {
471
- log.warn(`${LOG_PREFIX} Failed to load ${SIDEBAR_PROJECT_ORDER_FILE}, ignoring saved order:`, String(error))
472
- this.sidebarProjectOrder = []
473
- }
474
- return
475
- }
476
-
477
- const legacySidebarProjectOrder = await this.loadLegacySidebarProjectOrder()
478
- this.sidebarProjectOrder = legacySidebarProjectOrder
479
- if (legacySidebarProjectOrder.length > 0) {
480
- await this.writeSidebarProjectOrderFile(legacySidebarProjectOrder)
481
- }
482
- }
483
-
484
- private async loadLegacySidebarProjectOrder() {
485
- const fromProjectsLog = await this.readLegacySidebarProjectOrderFromProjectsLog()
486
- if (fromProjectsLog.length > 0) {
487
- return fromProjectsLog
488
- }
489
- return [...this.legacySidebarProjectOrder]
490
- }
491
-
492
- private async readLegacySidebarProjectOrderFromProjectsLog() {
493
- if (!(await this.storage.exists(this.projectsLogPath))) return []
494
-
495
- const text = await this.storage.readText(this.projectsLogPath)
496
- if (!text.trim()) return []
497
-
498
- const lines = text.split("\n")
499
- let lastNonEmpty = -1
500
- for (let index = lines.length - 1; index >= 0; index -= 1) {
501
- if (lines[index].trim()) {
502
- lastNonEmpty = index
503
- break
504
- }
505
- }
506
-
507
- let projectIds: string[] = []
508
- for (let index = 0; index < lines.length; index += 1) {
509
- const line = lines[index].trim()
510
- if (!line) continue
511
- try {
512
- const event: { v?: number; type?: string; projectIds?: AnyValue } = JSON.parse(line)
513
- if (event.v !== STORE_VERSION || event.type !== "sidebar_project_order_set") {
514
- continue
515
- }
516
- projectIds = normalizeSidebarProjectOrder(event.projectIds)
517
- } catch (error) {
518
- if (index === lastNonEmpty) {
519
- log.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(this.projectsLogPath)} while migrating sidebar order`)
520
- return projectIds
521
- }
522
- log.warn(`${LOG_PREFIX} Failed to migrate sidebar order from ${path.basename(this.projectsLogPath)}:`, String(error))
523
- return []
524
- }
525
- }
526
-
527
- return projectIds
528
- }
529
-
530
- private async writeSidebarProjectOrderFile(projectIds: string[]) {
531
- await this.storage.mkdir(this.dataDir)
532
- await this.storage.writeText(this.sidebarProjectOrderPath, `${JSON.stringify(projectIds, null, 2)}\n`)
533
- }
534
-
535
- private async replayLogs() {
536
- if (this.storageReset) return
537
- const replayEvents = [
538
- ...await this.loadReplayEvents(this.projectsLogPath, 0),
539
- ...await this.loadReplayEvents(this.stacksLogPath, 1),
540
- ...await this.loadReplayEvents(this.chatsLogPath, 2),
541
- ...await this.loadReplayEvents(this.messagesLogPath, 3),
542
- ...await this.loadReplayEvents(this.queuedMessagesLogPath, 4),
543
- ...await this.loadReplayEvents(this.turnsLogPath, 5),
544
- ...await this.loadReplayEvents(this.schedulesLogPath, 6),
545
- ...await this.loadReplayEvents(this.toolRequestsLogPath, 7),
546
- ...await this.loadReplayEvents(this.orchLogPath, 8),
547
- ]
548
- if (this.storageReset) return
549
-
550
- replayEvents
551
- .sort((left, right) => (
552
- left.event.timestamp - right.event.timestamp
553
- || getReplayEventPriority(left.event) - getReplayEventPriority(right.event)
554
- || left.sourceIndex - right.sourceIndex
555
- || left.lineIndex - right.lineIndex
556
- ))
557
- .forEach(({ event }) => {
558
- this.applyEvent(event)
559
- })
560
- this.replayChatProvider.clear()
561
- }
562
-
563
- private async loadReplayEvents(filePath: string, sourceIndex: number): Promise<ParsedReplayEvent[]> {
564
- if (!(await this.storage.exists(filePath))) return []
565
- const text = await this.storage.readText(filePath)
566
- if (!text.trim()) return []
567
-
568
- const parsedEvents: ParsedReplayEvent[] = []
569
- const lines = text.split("\n")
570
- let lastNonEmpty = -1
571
- for (let index = lines.length - 1; index >= 0; index -= 1) {
572
- if (lines[index].trim()) {
573
- lastNonEmpty = index
574
- break
575
- }
576
- }
577
-
578
- for (let index = 0; index < lines.length; index += 1) {
579
- const line = lines[index].trim()
580
- if (!line) continue
581
- try {
582
- const event: StoreEvent & { v?: number; type?: string } = JSON.parse(line)
583
- if (event.v !== STORE_VERSION) {
584
- log.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
585
- await this.clearStorage()
586
- return []
587
- }
588
- if (event.type === "sidebar_project_order_set") {
589
- continue
590
- }
591
- parsedEvents.push({
592
- event,
593
- sourceIndex,
594
- lineIndex: index,
595
- })
596
- } catch (error) {
597
- if (index === lastNonEmpty) {
598
- log.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
599
- return parsedEvents
600
- }
601
- log.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, String(error))
602
- await this.clearStorage()
603
- return []
604
- }
605
- }
606
-
607
- return parsedEvents
608
- }
609
-
610
- private applyEvent(event: StoreEvent) {
611
- if ("kind" in event) {
612
- this.applyAutoContinueEvent(event)
613
- return
614
- }
615
- const e: Exclude<StoreEvent, AutoContinueEvent> = event
616
- switch (e.type) {
617
- case "project_opened": {
618
- const localPath = resolveLocalPath(e.localPath)
619
- const project = {
620
- id: e.projectId,
621
- localPath,
622
- title: e.title,
623
- createdAt: e.timestamp,
624
- updatedAt: e.timestamp,
625
- }
626
- this.state.projectsById.set(project.id, project)
627
- this.state.projectIdsByPath.set(localPath, project.id)
628
- break
629
- }
630
- case "project_removed": {
631
- const project = this.state.projectsById.get(e.projectId)
632
- if (!project) break
633
- project.deletedAt = e.timestamp
634
- project.updatedAt = e.timestamp
635
- this.state.projectIdsByPath.delete(project.localPath)
636
- break
637
- }
638
- case "sidebar_project_order_set": {
639
- this.state.sidebarProjectOrder = [...e.projectIds]
640
- break
641
- }
642
- case "project_star_set": {
643
- const project = this.state.projectsById.get(e.projectId)
644
- if (!project) break
645
- if (e.starredAt == null) {
646
- delete project.starredAt
647
- } else {
648
- project.starredAt = e.starredAt
649
- }
650
- project.updatedAt = e.timestamp
651
- break
652
- }
653
- case "chat_created": {
654
- const chat: ChatRecord = {
655
- id: e.chatId,
656
- projectId: e.projectId,
657
- title: e.title,
658
- createdAt: e.timestamp,
659
- updatedAt: e.timestamp,
660
- unread: false,
661
- provider: null,
662
- planMode: false,
663
- sessionTokensByProvider: {},
664
- sourceHash: null,
665
- pendingForkSessionToken: null,
666
- hasMessages: false,
667
- lastTurnOutcome: null,
668
- }
669
- if (e.stackId !== undefined) chat.stackId = e.stackId
670
- if (e.stackBindings !== undefined) chat.stackBindings = e.stackBindings.map((b) => ({ ...b }))
671
- this.state.chatsById.set(chat.id, chat)
672
- this.replayChatProvider.set(e.chatId, null)
673
- this.state.subagentRunsByChatId.set(e.chatId, new Map())
674
- this.updateTiming(e.chatId, e.timestamp, "idle")
675
- break
676
- }
677
- case "chat_renamed": {
678
- const chat = this.state.chatsById.get(e.chatId)
679
- if (!chat) break
680
- chat.title = e.title
681
- chat.updatedAt = e.timestamp
682
- break
683
- }
684
- case "chat_deleted": {
685
- const chat = this.state.chatsById.get(e.chatId)
686
- if (!chat) break
687
- chat.deletedAt = e.timestamp
688
- chat.updatedAt = e.timestamp
689
- this.state.queuedMessagesByChatId.delete(e.chatId)
690
- this.state.autoContinueEventsByChatId.delete(e.chatId)
691
- this.state.chatTimingsByChatId.delete(e.chatId)
692
- this.state.subagentRunsByChatId.delete(e.chatId)
693
- break
694
- }
695
- case "chat_archived": {
696
- const chat = this.state.chatsById.get(e.chatId)
697
- if (!chat) break
698
- chat.archivedAt = e.timestamp
699
- chat.updatedAt = e.timestamp
700
- break
701
- }
702
- case "chat_unarchived": {
703
- const chat = this.state.chatsById.get(e.chatId)
704
- if (!chat) break
705
- delete chat.archivedAt
706
- chat.updatedAt = e.timestamp
707
- break
708
- }
709
- case "chat_provider_set": {
710
- const chat = this.state.chatsById.get(e.chatId)
711
- if (!chat) break
712
- chat.provider = e.provider
713
- chat.updatedAt = e.timestamp
714
- this.replayChatProvider.set(e.chatId, e.provider)
715
- break
716
- }
717
- case "chat_plan_mode_set": {
718
- const chat = this.state.chatsById.get(e.chatId)
719
- if (!chat) break
720
- chat.planMode = e.planMode
721
- chat.updatedAt = e.timestamp
722
- break
723
- }
724
- case "chat_read_state_set": {
725
- const chat = this.state.chatsById.get(e.chatId)
726
- if (!chat) break
727
- chat.unread = e.unread
728
- chat.updatedAt = e.timestamp
729
- break
730
- }
731
- case "chat_source_hash_set": {
732
- const chat = this.state.chatsById.get(e.chatId)
733
- if (!chat) break
734
- chat.sourceHash = e.sourceHash
735
- chat.updatedAt = e.timestamp
736
- break
737
- }
738
- case "chat_policy_override_set": {
739
- const chat = this.state.chatsById.get(e.chatId)
740
- if (!chat) break
741
- chat.policyOverride = e.policyOverride
742
- chat.updatedAt = e.timestamp
743
- break
744
- }
745
- case "chat_compact_failures_set": {
746
- const chat = this.state.chatsById.get(e.chatId)
747
- if (!chat) break
748
- chat.compactFailureCount = e.compactFailureCount
749
- chat.updatedAt = e.timestamp
750
- break
751
- }
752
- case "message_appended": {
753
- this.applyMessageMetadata(e.chatId, e.entry)
754
- const existing = this.legacyMessagesByChatId.get(e.chatId) ?? []
755
- existing.push({ ...e.entry })
756
- this.legacyMessagesByChatId.set(e.chatId, existing)
757
- break
758
- }
759
- case "queued_message_enqueued": {
760
- const existing = this.state.queuedMessagesByChatId.get(e.chatId) ?? []
761
- existing.push({
762
- ...e.message,
763
- attachments: [...e.message.attachments],
764
- })
765
- this.state.queuedMessagesByChatId.set(e.chatId, existing)
766
- const chat = this.state.chatsById.get(e.chatId)
767
- if (chat) {
768
- chat.updatedAt = e.timestamp
769
- }
770
- break
771
- }
772
- case "queued_message_removed": {
773
- const existing = this.state.queuedMessagesByChatId.get(e.chatId) ?? []
774
- const next = existing.filter((entry) => entry.id !== e.queuedMessageId)
775
- if (next.length > 0) {
776
- this.state.queuedMessagesByChatId.set(e.chatId, next)
777
- } else {
778
- this.state.queuedMessagesByChatId.delete(e.chatId)
779
- }
780
- const chat = this.state.chatsById.get(e.chatId)
781
- if (chat) {
782
- chat.updatedAt = e.timestamp
783
- }
784
- break
785
- }
786
- case "turn_started": {
787
- const chat = this.state.chatsById.get(e.chatId)
788
- if (!chat) break
789
- chat.updatedAt = e.timestamp
790
- this.updateTiming(e.chatId, e.timestamp, "running", true, false)
791
- break
792
- }
793
- case "turn_finished": {
794
- const chat = this.state.chatsById.get(e.chatId)
795
- if (!chat) break
796
- chat.updatedAt = e.timestamp
797
- chat.unread = true
798
- chat.lastTurnOutcome = "success"
799
- this.updateTiming(e.chatId, e.timestamp, "idle", false, true)
800
- break
801
- }
802
- case "turn_failed": {
803
- const chat = this.state.chatsById.get(e.chatId)
804
- if (!chat) break
805
- chat.updatedAt = e.timestamp
806
- chat.unread = true
807
- chat.lastTurnOutcome = "failed"
808
- this.updateTiming(e.chatId, e.timestamp, "failed", false, true)
809
- break
810
- }
811
- case "turn_cancelled": {
812
- const chat = this.state.chatsById.get(e.chatId)
813
- if (!chat) break
814
- chat.updatedAt = e.timestamp
815
- chat.lastTurnOutcome = "cancelled"
816
- this.updateTiming(e.chatId, e.timestamp, "idle", false, true)
817
- break
818
- }
819
- case "session_token_set": {
820
- const chat = this.state.chatsById.get(e.chatId)
821
- if (!chat) break
822
- const provider = e.provider ?? this.replayChatProvider.get(e.chatId) ?? chat.provider
823
- if (!provider) break
824
- chat.sessionTokensByProvider = {
825
- ...chat.sessionTokensByProvider,
826
- [provider]: e.sessionToken,
827
- }
828
- chat.updatedAt = e.timestamp
829
- break
830
- }
831
- case "session_commands_loaded": {
832
- const chat = this.state.chatsById.get(e.chatId)
833
- if (!chat) break
834
- chat.slashCommands = e.commands.map((c) => ({ ...c }))
835
- chat.updatedAt = e.timestamp
836
- break
837
- }
838
- case "pending_fork_session_token_set": {
839
- const chat = this.state.chatsById.get(e.chatId)
840
- if (!chat) break
841
- if (e.pendingForkSessionToken == null) {
842
- chat.pendingForkSessionToken = null
843
- } else {
844
- const provider = e.provider ?? this.replayChatProvider.get(e.chatId) ?? chat.provider
845
- if (!provider) break
846
- chat.pendingForkSessionToken = { provider, token: e.pendingForkSessionToken }
847
- }
848
- chat.updatedAt = e.timestamp
849
- break
850
- }
851
- case "stack_added": {
852
- const record: StackRecord = {
853
- id: e.stackId,
854
- title: e.title,
855
- projectIds: [...e.projectIds],
856
- createdAt: e.timestamp,
857
- updatedAt: e.timestamp,
858
- }
859
- this.state.stacksById.set(record.id, record)
860
- break
861
- }
862
- case "stack_removed": {
863
- const stack = this.state.stacksById.get(e.stackId)
864
- if (!stack || stack.deletedAt) break
865
- stack.deletedAt = e.timestamp
866
- stack.updatedAt = e.timestamp
867
- break
868
- }
869
- case "stack_renamed": {
870
- const stack = this.state.stacksById.get(e.stackId)
871
- if (!stack || stack.deletedAt) break
872
- stack.title = e.title
873
- stack.updatedAt = e.timestamp
874
- break
875
- }
876
- case "stack_project_added": {
877
- const stack = this.state.stacksById.get(e.stackId)
878
- if (!stack || stack.deletedAt) break
879
- if (stack.projectIds.includes(e.projectId)) break
880
- stack.projectIds = [...stack.projectIds, e.projectId]
881
- stack.updatedAt = e.timestamp
882
- break
883
- }
884
- case "stack_project_removed": {
885
- const stack = this.state.stacksById.get(e.stackId)
886
- if (!stack || stack.deletedAt) break
887
- const next = stack.projectIds.filter((id) => id !== e.projectId)
888
- stack.projectIds = next
889
- stack.updatedAt = e.timestamp
890
- break
891
- }
892
- case "subagent_run_started": {
893
- const map = this.state.subagentRunsByChatId.get(e.chatId)
894
- if (!map) break
895
- map.set(e.runId, {
896
- runId: e.runId,
897
- chatId: e.chatId,
898
- subagentId: e.subagentId,
899
- subagentName: e.subagentName,
900
- provider: e.provider,
901
- model: e.model,
902
- status: "running",
903
- parentUserMessageId: e.parentUserMessageId,
904
- parentRunId: e.parentRunId,
905
- depth: e.depth,
906
- startedAt: e.timestamp,
907
- finishedAt: null,
908
- finalText: null,
909
- error: null,
910
- usage: null,
911
- entries: [],
912
- pendingTool: null,
913
- })
914
- break
915
- }
916
- case "subagent_message_delta": {
917
- const map = this.state.subagentRunsByChatId.get(e.chatId)
918
- const run = map?.get(e.runId)
919
- if (!run) break
920
- run.finalText = (run.finalText ?? "") + e.content
921
- break
922
- }
923
- case "subagent_entry_appended": {
924
- const map = this.state.subagentRunsByChatId.get(e.chatId)
925
- const run = map?.get(e.runId)
926
- if (!run) break
927
- run.entries.push(e.entry)
928
- // If the entry carries usage (the SDK's terminal "result" message), mirror
929
- // it onto run.usage so callers can read it without scanning entries.
930
- if (e.entry.kind === "result") {
931
- const usage = e.entry.usage
932
- const cost = e.entry.costUsd
933
- run.usage = {
934
- inputTokens: usage?.inputTokens,
935
- outputTokens: usage?.outputTokens,
936
- cachedInputTokens: usage?.cachedInputTokens,
937
- costUsd: cost,
938
- }
939
- }
940
- break
941
- }
942
- case "subagent_run_completed": {
943
- const map = this.state.subagentRunsByChatId.get(e.chatId)
944
- const run = map?.get(e.runId)
945
- if (!run) break
946
- run.status = "completed"
947
- run.finishedAt = e.timestamp
948
- run.finalText = e.finalContent
949
- // Merge: prefer e.usage if present, otherwise keep what subagent_entry_appended
950
- // already mirrored. Otherwise null. Without this guard a streaming run
951
- // whose completion event omits usage would silently erase it.
952
- run.usage = e.usage ?? run.usage ?? null
953
- break
954
- }
955
- case "subagent_run_failed": {
956
- const map = this.state.subagentRunsByChatId.get(e.chatId)
957
- const run = map?.get(e.runId)
958
- if (!run) break
959
- run.status = "failed"
960
- run.finishedAt = e.timestamp
961
- run.error = e.error
962
- run.pendingTool = null
963
- break
964
- }
965
- case "subagent_run_cancelled": {
966
- const map = this.state.subagentRunsByChatId.get(e.chatId)
967
- const run = map?.get(e.runId)
968
- if (!run) break
969
- run.status = "cancelled"
970
- run.finishedAt = e.timestamp
971
- run.pendingTool = null
972
- break
973
- }
974
- case "subagent_tool_pending": {
975
- const map = this.state.subagentRunsByChatId.get(e.chatId)
976
- const run = map?.get(e.runId)
977
- if (!run) break
978
- run.pendingTool = {
979
- toolUseId: e.toolUseId,
980
- toolKind: e.toolKind,
981
- input: e.input,
982
- requestedAt: e.timestamp,
983
- }
984
- break
985
- }
986
- case "subagent_tool_resolved": {
987
- const map = this.state.subagentRunsByChatId.get(e.chatId)
988
- const run = map?.get(e.runId)
989
- if (!run) break
990
- run.pendingTool = null
991
- const syntheticEntry: TranscriptEntry = {
992
- kind: "tool_result",
993
- _id: `${e.runId}:${e.toolUseId}:resolved`,
994
- createdAt: e.timestamp,
995
- toolId: e.toolUseId,
996
- content: e.result,
997
- }
998
- run.entries.push(syntheticEntry)
999
- break
1000
- }
1001
- case "tool_request_put": {
1002
- this.state.toolRequestsById.set(e.request.id, { ...e.request })
1003
- break
1004
- }
1005
- case "tool_request_resolved": {
1006
- const existing = this.state.toolRequestsById.get(e.id)
1007
- if (!existing) break
1008
- this.state.toolRequestsById.set(e.id, {
1009
- ...existing,
1010
- status: e.status,
1011
- decision: e.decision ?? existing.decision,
1012
- resolvedAt: e.resolvedAt,
1013
- mismatchReason: e.mismatchReason,
1014
- })
1015
- break
1016
- }
1017
- case "orch_run_created":
1018
- case "orch_worktree_provisioned":
1019
- case "orch_worktree_init_started":
1020
- case "orch_worktree_init_completed":
1021
- case "orch_task_claimed":
1022
- case "orch_phase_started":
1023
- case "orch_phase_completed":
1024
- case "orch_gate_opened":
1025
- case "orch_gate_resolved":
1026
- case "orch_scope_overlap_flagged":
1027
- case "orch_config_warning":
1028
- case "orch_verify_started":
1029
- case "orch_verify_completed":
1030
- case "orch_task_committed":
1031
- case "orch_task_failed":
1032
- case "orch_task_requeued":
1033
- case "orch_run_completed":
1034
- case "orch_run_cancelled":
1035
- this.applyOrchestrationEvent(e)
1036
- break
1037
- }
1038
- }
1039
-
1040
- private updateTiming(chatId: string, eventTs: number, nextStatus: ChatTimingState["status"], onTurnStart?: boolean, onTurnFinish?: boolean) {
1041
- const prev = this.state.chatTimingsByChatId.get(chatId)
1042
- if (!prev) {
1043
- // chat_created path: seed
1044
- this.state.chatTimingsByChatId.set(chatId, {
1045
- status: nextStatus,
1046
- stateEnteredAt: eventTs,
1047
- activeSessionStartedAt: eventTs,
1048
- lastTurnStartedAt: null,
1049
- lastTurnDurationMs: null,
1050
- cumulativeMs: { idle: 0, starting: 0, running: 0, failed: 0 },
1051
- })
1052
- return
1053
- }
1054
-
1055
- const segmentMs = Math.max(0, eventTs - prev.stateEnteredAt)
1056
- let activeSessionStartedAt = prev.activeSessionStartedAt
1057
- let cumulativeMs = { ...prev.cumulativeMs }
1058
-
1059
- // Detect long idle gap when leaving idle -> something
1060
- if (prev.status === "idle" && nextStatus !== "idle" && segmentMs > ACTIVE_SESSION_IDLE_GAP_MS) {
1061
- activeSessionStartedAt = eventTs
1062
- cumulativeMs = { idle: 0, starting: 0, running: 0, failed: 0 }
1063
- } else {
1064
- cumulativeMs[prev.status] += segmentMs
1065
- }
1066
-
1067
- let lastTurnStartedAt = prev.lastTurnStartedAt
1068
- let lastTurnDurationMs = prev.lastTurnDurationMs
1069
- if (onTurnStart) lastTurnStartedAt = eventTs
1070
- if (onTurnFinish && lastTurnStartedAt != null) lastTurnDurationMs = Math.max(0, eventTs - lastTurnStartedAt)
1071
-
1072
- this.state.chatTimingsByChatId.set(chatId, {
1073
- status: nextStatus,
1074
- stateEnteredAt: eventTs,
1075
- activeSessionStartedAt,
1076
- lastTurnStartedAt,
1077
- lastTurnDurationMs,
1078
- cumulativeMs,
113
+ await initializeEventStore(this.buildInitDeps(), {
114
+ loadTunnelEvents: () => this.loadTunnelEvents(),
115
+ loadShareEvents: () => this.loadShareEvents(),
116
+ hasLegacyTranscriptData: () => this.hasLegacyTranscriptData(),
117
+ snapshotAndTruncateLogs: () => this.snapshotAndTruncateLogs(),
1079
118
  })
1080
119
  }
1081
120
 
1082
- private applyAutoContinueEvent(event: AutoContinueEvent) {
1083
- const existing = this.state.autoContinueEventsByChatId.get(event.chatId) ?? []
1084
- existing.push(event)
1085
- this.state.autoContinueEventsByChatId.set(event.chatId, existing)
1086
- }
1087
-
1088
- private applyMessageMetadata(chatId: string, entry: TranscriptEntry) {
1089
- const chat = this.state.chatsById.get(chatId)
1090
- if (!chat) return
1091
- chat.hasMessages = true
1092
- if (entry.kind === "user_prompt") {
1093
- chat.lastMessageAt = entry.createdAt
1094
- }
1095
- chat.updatedAt = Math.max(chat.updatedAt, entry.createdAt)
121
+ private applyEvent(event: StoreEvent) {
122
+ applyStoreEvent(event, this.state, this.legacyMessagesByChatId, this.replayChatProvider)
1096
123
  }
1097
124
 
1098
125
  private enqueueDiskAppend(filePath: string, payload: string): void {
@@ -1112,390 +139,192 @@ export class EventStore implements PushEventStore {
1112
139
  return this.writeChain
1113
140
  }
1114
141
 
1115
- private transcriptPath(chatId: string) {
1116
- return path.join(this.transcriptsDir, `${chatId}.jsonl`)
1117
- }
1118
-
1119
- private loadTranscriptFromDisk(chatId: string) {
1120
- const transcriptPath = this.transcriptPath(chatId)
1121
- if (!this.storage.existsSync(transcriptPath)) {
1122
- return []
1123
- }
142
+ // ─── Deps builders ──────────────────────────────────────────────────────────
1124
143
 
1125
- const text = this.storage.readTextSync(transcriptPath)
1126
- if (!text.trim()) return []
1127
-
1128
- const entries: TranscriptEntry[] = []
1129
- const seen = this.getSeenMessageIds(chatId)
1130
- for (const rawLine of text.split("\n")) {
1131
- const line = rawLine.trim()
1132
- if (!line) continue
1133
- const entry: TranscriptEntry & { messageId?: string } = JSON.parse(line)
1134
- entries.push(entry)
1135
- const mid = entry.messageId
1136
- if (typeof mid === "string" && mid.length > 0) {
1137
- seen.add(mid)
1138
- }
144
+ private buildInitDeps(): EventStoreInitDeps {
145
+ return {
146
+ storage: this.storage,
147
+ dataDir: this.dataDir,
148
+ snapshotPath: this.snapshotPath,
149
+ projectsLogPath: this.projectsLogPath,
150
+ chatsLogPath: this.chatsLogPath,
151
+ messagesLogPath: this.messagesLogPath,
152
+ queuedMessagesLogPath: this.queuedMessagesLogPath,
153
+ turnsLogPath: this.turnsLogPath,
154
+ schedulesLogPath: this.schedulesLogPath,
155
+ tunnelLogPath: this.tunnelLogPath,
156
+ sharesLogPath: this.sharesLogPath,
157
+ pushLogPath: this.pushLogPath,
158
+ stacksLogPath: this.stacksLogPath,
159
+ toolRequestsLogPath: this.toolRequestsLogPath,
160
+ orchLogPath: this.orchLogPath,
161
+ transcriptsDir: this.transcriptsDir,
162
+ sidebarProjectOrderPath: this.sidebarProjectOrderPath,
163
+ state: this.state,
164
+ legacyMessagesByChatId: this.legacyMessagesByChatId,
165
+ tunnelEventsByChatId: this.tunnelEventsByChatId,
166
+ cachedTranscriptRef: this.cachedTranscriptRef,
167
+ sidebarProjectOrderRef: this.sidebarProjectOrderRef,
168
+ getLegacySidebarProjectOrder: () => this.legacySidebarProjectOrder,
169
+ setLegacySidebarProjectOrder: (v) => { this.legacySidebarProjectOrder = v },
170
+ getSnapshotHasLegacyMessages: () => this.snapshotHasLegacyMessages,
171
+ setSnapshotHasLegacyMessages: (v) => { this.snapshotHasLegacyMessages = v },
172
+ getStorageReset: () => this.storageReset,
173
+ setStorageReset: (v) => { this.storageReset = v },
174
+ replayChatProvider: this.replayChatProvider,
175
+ applyEvent: (event) => { this.applyEvent(event) },
176
+ }
177
+ }
178
+
179
+ private buildMessageReadDeps(): MessageRead.MessageReadDeps {
180
+ return {
181
+ storage: this.storage,
182
+ transcriptsDir: this.transcriptsDir,
183
+ cachedTranscriptRef: this.cachedTranscriptRef,
184
+ legacyMessagesByChatId: this.legacyMessagesByChatId,
185
+ seenMessageIdsByChatId: this.seenMessageIdsByChatId,
186
+ queuedMessagesByChatId: this.state.queuedMessagesByChatId,
187
+ chatsById: this.state.chatsById,
188
+ listPendingToolRequests: (chatId) => this.listPendingToolRequests(chatId),
1139
189
  }
1140
- return entries
1141
190
  }
1142
191
 
1143
- private getSeenMessageIds(chatId: string): Set<string> {
1144
- let set = this.seenMessageIdsByChatId.get(chatId)
1145
- if (!set) {
1146
- set = new Set<string>()
1147
- this.seenMessageIdsByChatId.set(chatId, set)
192
+ private buildPeripheralEventsDeps(): PeripheralEvents.PeripheralEventsDeps {
193
+ return {
194
+ storage: this.storage,
195
+ tunnelLogPath: this.tunnelLogPath,
196
+ sharesLogPath: this.sharesLogPath,
197
+ pushLogPath: this.pushLogPath,
198
+ tunnelEventsByChatId: this.tunnelEventsByChatId,
199
+ shareEventsAll: this.shareEventsAll,
200
+ getWriteChain: () => this.writeChain,
201
+ setWriteChain: (p) => { this.writeChain = p },
1148
202
  }
1149
- return set
1150
203
  }
1151
204
 
1152
- async openProject(localPath: string, title?: string) {
1153
- const normalized = resolveLocalPath(localPath)
1154
- const existingId = this.state.projectIdsByPath.get(normalized)
1155
- if (existingId) {
1156
- const existing = this.state.projectsById.get(existingId)
1157
- if (existing && !existing.deletedAt) {
1158
- return existing
1159
- }
1160
- }
1161
-
1162
- const hiddenProject = [...this.state.projectsById.values()]
1163
- .find((project) => project.localPath === normalized && project.deletedAt)
1164
- const projectId = hiddenProject?.id ?? crypto.randomUUID()
1165
- const event: ProjectEvent = {
1166
- v: STORE_VERSION,
1167
- type: "project_opened",
1168
- timestamp: Date.now(),
1169
- projectId,
1170
- localPath: normalized,
1171
- title: title?.trim() || path.basename(normalized) || normalized,
205
+ private buildEntityWriteDeps(): EntityWrite.EntityWriteDeps {
206
+ return {
207
+ storage: this.storage,
208
+ dataDir: this.dataDir,
209
+ sidebarProjectOrderPath: this.sidebarProjectOrderPath,
210
+ projectsLogPath: this.projectsLogPath,
211
+ chatsLogPath: this.chatsLogPath,
212
+ queuedMessagesLogPath: this.queuedMessagesLogPath,
213
+ stacksLogPath: this.stacksLogPath,
214
+ projectsById: this.state.projectsById,
215
+ projectIdsByPath: this.state.projectIdsByPath,
216
+ chatsById: this.state.chatsById,
217
+ queuedMessagesByChatId: this.state.queuedMessagesByChatId,
218
+ stacksById: this.state.stacksById,
219
+ sidebarProjectOrderRef: this.sidebarProjectOrderRef,
220
+ getWriteChain: () => this.writeChain,
221
+ setWriteChain: (p) => { this.writeChain = p },
222
+ append: (filePath, event) => this.append(filePath, event),
223
+ }
224
+ }
225
+
226
+ private buildSessionWriteDeps(): EntityWrite.SessionWriteDeps {
227
+ return {
228
+ chatsById: this.state.chatsById,
229
+ turnsLogPath: this.turnsLogPath,
230
+ schedulesLogPath: this.schedulesLogPath,
231
+ append: (filePath, event) => this.append(filePath, event),
1172
232
  }
1173
- await this.append(this.projectsLogPath, event)
1174
- return this.state.projectsById.get(projectId)!
1175
233
  }
1176
234
 
1177
- async removeProject(projectId: string) {
1178
- const project = this.getProject(projectId)
1179
- if (!project) {
1180
- throw new Error("Project not found")
1181
- }
1182
-
1183
- const event: ProjectEvent = {
1184
- v: STORE_VERSION,
1185
- type: "project_removed",
1186
- timestamp: Date.now(),
1187
- projectId,
235
+ private buildChatTranscriptWriteDeps(): TranscriptWrite.ChatTranscriptWriteDeps {
236
+ return {
237
+ storage: this.storage,
238
+ transcriptsDir: this.transcriptsDir,
239
+ dataDir: this.dataDir,
240
+ cachedTranscriptRef: this.cachedTranscriptRef,
241
+ seenMessageIdsByChatId: this.seenMessageIdsByChatId,
242
+ chatsById: this.state.chatsById,
243
+ toolRequestsById: this.state.toolRequestsById,
244
+ chatsLogPath: this.chatsLogPath,
245
+ turnsLogPath: this.turnsLogPath,
246
+ getWriteChain: () => this.writeChain,
247
+ setWriteChain: (p) => { this.writeChain = p },
248
+ append: (filePath, event) => this.append(filePath, event),
249
+ getMessages: (chatId) => this.getMessages(chatId),
250
+ getSeenMessageIds: (chatId) => this.getSeenMessageIds(chatId),
251
+ listPendingToolRequests: (chatId) => this.listPendingToolRequests(chatId),
252
+ }
253
+ }
254
+
255
+ private buildOrchSubscriptionDeps(): OrchSubscription.OrchSubscriptionDeps {
256
+ return {
257
+ orchRunsById: this.state.orchRunsById,
258
+ orchLogPath: this.orchLogPath,
259
+ orchRunsSubscribers: this.orchSubscriptionState.orchRunsSubscribers,
260
+ applyEvent: (e) => { this.applyEvent(e) },
261
+ enqueueDiskAppend: (fp, p) => { this.enqueueDiskAppend(fp, p) },
1188
262
  }
1189
- await this.append(this.projectsLogPath, event)
1190
263
  }
1191
264
 
1192
- async setProjectStar(projectId: string, starred: boolean) {
1193
- const project = this.getProject(projectId)
1194
- if (!project) {
1195
- throw new Error("Project not found")
1196
- }
1197
- const now = Date.now()
1198
- const event: ProjectEvent = {
1199
- v: STORE_VERSION,
1200
- type: "project_star_set",
1201
- timestamp: now,
1202
- projectId,
1203
- starredAt: starred ? now : null,
265
+ private buildToolRequestWriteDeps(): ToolRequestWriteDeps {
266
+ return {
267
+ toolRequestsById: this.state.toolRequestsById,
268
+ toolRequestsLogPath: this.toolRequestsLogPath,
269
+ append: (fp, e) => this.append(fp, e),
1204
270
  }
1205
- await this.append(this.projectsLogPath, event)
1206
271
  }
1207
272
 
1208
- async createStack(title: string, projectIds: string[]): Promise<StackRecord> {
1209
- const trimmed = title.trim()
1210
- if (trimmed === "") throw new Error("Stack title cannot be empty")
1211
- if (projectIds.length < 2) throw new Error("Stack requires at least 2 projects")
1212
- if (new Set(projectIds).size !== projectIds.length) throw new Error("Stack projectIds contain duplicates")
1213
- for (const projectId of projectIds) {
1214
- const project = this.state.projectsById.get(projectId)
1215
- if (!project || project.deletedAt) throw new Error(`Project not found: ${projectId}`)
1216
- }
1217
- const stackId = crypto.randomUUID()
1218
- const event: StackEvent = {
1219
- v: STORE_VERSION,
1220
- type: "stack_added",
1221
- timestamp: Date.now(),
1222
- stackId,
1223
- title: trimmed,
1224
- projectIds: [...projectIds],
273
+ private buildAppendSubagentDeps(): AppendSubagentDeps {
274
+ return {
275
+ chatsById: this.state.chatsById,
276
+ turnsLogPath: this.turnsLogPath,
277
+ dataDir: this.dataDir,
278
+ applyEvent: (e) => { this.applyEvent(e) },
279
+ enqueueDiskAppend: (fp, p) => { this.enqueueDiskAppend(fp, p) },
1225
280
  }
1226
- await this.append(this.stacksLogPath, event)
1227
- return this.state.stacksById.get(stackId)!
1228
281
  }
1229
282
 
1230
- getStack(stackId: string): StackRecord | null {
1231
- const stack = this.state.stacksById.get(stackId)
1232
- return stack && !stack.deletedAt ? stack : null
1233
- }
283
+ // ─── Private helpers ─────────────────────────────────────────────────────
1234
284
 
1235
- listStacks(): StackRecord[] {
1236
- return [...this.state.stacksById.values()].filter((s) => !s.deletedAt)
1237
- }
285
+ private getSeenMessageIds(chatId: string): Set<string> { return MessageRead.getSeenMessageIds(this.buildMessageReadDeps(), chatId) }
1238
286
 
1239
- async renameStack(stackId: string, title: string): Promise<void> {
1240
- const stack = this.state.stacksById.get(stackId)
1241
- if (!stack || stack.deletedAt) throw new Error("Stack not found")
1242
- const trimmed = title.trim()
1243
- if (trimmed === "") throw new Error("Stack title cannot be empty")
1244
- if (trimmed === stack.title) return
1245
- const event: StackEvent = {
1246
- v: STORE_VERSION,
1247
- type: "stack_renamed",
1248
- timestamp: Date.now(),
1249
- stackId,
1250
- title: trimmed,
1251
- }
1252
- await this.append(this.stacksLogPath, event)
1253
- }
287
+ async openProject(localPath: string, title?: string) { return EntityWrite.openProject(this.buildEntityWriteDeps(), localPath, title) }
1254
288
 
1255
- async removeStack(stackId: string): Promise<void> {
1256
- const stack = this.state.stacksById.get(stackId)
1257
- if (!stack) throw new Error("Stack not found")
1258
- if (stack.deletedAt) return
1259
- const event: StackEvent = {
1260
- v: STORE_VERSION,
1261
- type: "stack_removed",
1262
- timestamp: Date.now(),
1263
- stackId,
1264
- }
1265
- await this.append(this.stacksLogPath, event)
1266
- }
289
+ async removeProject(projectId: string) { return EntityWrite.removeProject(this.buildEntityWriteDeps(), projectId) }
1267
290
 
1268
- async addProjectToStack(stackId: string, projectId: string): Promise<void> {
1269
- const stack = this.state.stacksById.get(stackId)
1270
- if (!stack || stack.deletedAt) throw new Error("Stack not found")
1271
- const project = this.state.projectsById.get(projectId)
1272
- if (!project || project.deletedAt) throw new Error("Project not found")
1273
- if (stack.projectIds.includes(projectId)) return
1274
- const event: StackEvent = {
1275
- v: STORE_VERSION,
1276
- type: "stack_project_added",
1277
- timestamp: Date.now(),
1278
- stackId,
1279
- projectId,
1280
- }
1281
- await this.append(this.stacksLogPath, event)
1282
- }
291
+ async setProjectStar(projectId: string, starred: boolean) { return EntityWrite.setProjectStar(this.buildEntityWriteDeps(), projectId, starred) }
1283
292
 
1284
- async removeProjectFromStack(stackId: string, projectId: string): Promise<void> {
293
+ async createStack(title: string, projectIds: string[]): Promise<StackRecord> { return EntityWrite.createStack(this.buildEntityWriteDeps(), title, projectIds) }
294
+
295
+ getStack(stackId: string): StackRecord | null {
1285
296
  const stack = this.state.stacksById.get(stackId)
1286
- if (!stack || stack.deletedAt) throw new Error("Stack not found")
1287
- if (!stack.projectIds.includes(projectId)) return
1288
- if (stack.projectIds.length <= 2) {
1289
- throw new Error("Stack must keep at least 2 projects. Delete the stack instead.")
1290
- }
1291
- const event: StackEvent = {
1292
- v: STORE_VERSION,
1293
- type: "stack_project_removed",
1294
- timestamp: Date.now(),
1295
- stackId,
1296
- projectId,
1297
- }
1298
- await this.append(this.stacksLogPath, event)
297
+ return stack && !stack.deletedAt ? stack : null
1299
298
  }
1300
299
 
1301
- async setSidebarProjectOrder(projectIds: string[]) {
1302
- const validProjectIds = projectIds.filter((projectId) => {
1303
- const project = this.state.projectsById.get(projectId)
1304
- return Boolean(project && !project.deletedAt)
1305
- })
300
+ listStacks(): StackRecord[] { return [...this.state.stacksById.values()].filter((s) => !s.deletedAt) }
1306
301
 
1307
- const uniqueProjectIds = [...new Set(validProjectIds)]
1308
- const current = this.sidebarProjectOrder
1309
- if (
1310
- uniqueProjectIds.length === current.length
1311
- && uniqueProjectIds.every((projectId, index) => current[index] === projectId)
1312
- ) {
1313
- return
1314
- }
302
+ async renameStack(stackId: string, title: string): Promise<void> { return EntityWrite.renameStack(this.buildEntityWriteDeps(), stackId, title) }
1315
303
 
1316
- this.writeChain = this.writeChain.then(async () => {
1317
- await this.writeSidebarProjectOrderFile(uniqueProjectIds)
1318
- this.sidebarProjectOrder = [...uniqueProjectIds]
1319
- })
1320
- return this.writeChain
1321
- }
304
+ async removeStack(stackId: string): Promise<void> { return EntityWrite.removeStack(this.buildEntityWriteDeps(), stackId) }
305
+
306
+ async addProjectToStack(stackId: string, projectId: string): Promise<void> { return EntityWrite.addProjectToStack(this.buildEntityWriteDeps(), stackId, projectId) }
307
+
308
+ async removeProjectFromStack(stackId: string, projectId: string): Promise<void> { return EntityWrite.removeProjectFromStack(this.buildEntityWriteDeps(), stackId, projectId) }
309
+
310
+ async setSidebarProjectOrder(projectIds: string[]) { return EntityWrite.setSidebarProjectOrder(this.buildEntityWriteDeps(), projectIds) }
1322
311
 
1323
312
  async createChat(
1324
313
  projectId: string,
1325
314
  options?: { stackId?: string; stackBindings?: StackBinding[] },
1326
315
  ): Promise<import("./events").ChatRecord> {
1327
- const project = this.state.projectsById.get(projectId)
1328
- if (!project || project.deletedAt) {
1329
- throw new Error("Project not found")
1330
- }
1331
-
1332
- if (options?.stackId !== undefined || options?.stackBindings !== undefined) {
1333
- if (options.stackId === undefined || options.stackBindings === undefined) {
1334
- throw new Error("stackId and stackBindings must be provided together")
1335
- }
1336
- const stack = this.state.stacksById.get(options.stackId)
1337
- if (!stack || stack.deletedAt) throw new Error("Stack not found")
1338
- if (options.stackBindings.length === 0) throw new Error("stackBindings cannot be empty")
1339
- const primaries = options.stackBindings.filter((b) => b.role === "primary")
1340
- if (primaries.length !== 1) throw new Error("Exactly one primary binding required")
1341
- const seenProjects = new Set<string>()
1342
- for (const binding of options.stackBindings) {
1343
- if (seenProjects.has(binding.projectId)) {
1344
- throw new Error("Duplicate projectId in stackBindings")
1345
- }
1346
- seenProjects.add(binding.projectId)
1347
- if (!stack.projectIds.includes(binding.projectId)) {
1348
- throw new Error(`Binding projectId not a member of stack: ${binding.projectId}`)
1349
- }
1350
- const peerProject = this.state.projectsById.get(binding.projectId)
1351
- if (!peerProject || peerProject.deletedAt) {
1352
- throw new Error(`Project not found: ${binding.projectId}`)
1353
- }
1354
- if (typeof binding.worktreePath !== "string" || binding.worktreePath.trim() === "") {
1355
- throw new Error("worktreePath must be a non-empty string")
1356
- }
1357
- }
1358
- if (primaries[0].projectId !== projectId) {
1359
- throw new Error("Primary binding projectId must match createChat projectId")
1360
- }
1361
- }
1362
-
1363
- const chatId = crypto.randomUUID()
1364
- const event: ChatEvent = {
1365
- v: STORE_VERSION,
1366
- type: "chat_created",
1367
- timestamp: Date.now(),
1368
- chatId,
1369
- projectId,
1370
- title: "New Chat",
1371
- ...(options?.stackId !== undefined ? { stackId: options.stackId } : {}),
1372
- ...(options?.stackBindings !== undefined ? { stackBindings: options.stackBindings.map((b) => ({ ...b })) } : {}),
1373
- }
1374
- await this.append(this.chatsLogPath, event)
1375
- return this.state.chatsById.get(chatId)!
1376
- }
1377
-
1378
- async forkChat(sourceChatId: string) {
1379
- const sourceChat = this.requireChat(sourceChatId)
1380
- const sourceProvider = sourceChat.provider
1381
- if (!sourceProvider) {
1382
- throw new Error("Chat cannot be forked")
1383
- }
1384
- const sourceSessionToken =
1385
- sourceChat.sessionTokensByProvider[sourceProvider]
1386
- ?? (sourceChat.pendingForkSessionToken?.provider === sourceProvider
1387
- ? sourceChat.pendingForkSessionToken.token
1388
- : null)
1389
- if (!sourceSessionToken) {
1390
- throw new Error("Chat cannot be forked")
1391
- }
1392
-
1393
- const chatId = crypto.randomUUID()
1394
- const createdAt = Date.now()
1395
- const createEvent: ChatEvent = {
1396
- v: STORE_VERSION,
1397
- type: "chat_created",
1398
- timestamp: createdAt,
1399
- chatId,
1400
- projectId: sourceChat.projectId,
1401
- title: getForkedChatTitle(sourceChat.title),
1402
- ...(sourceChat.stackId !== undefined ? { stackId: sourceChat.stackId } : {}),
1403
- ...(sourceChat.stackBindings !== undefined
1404
- ? { stackBindings: sourceChat.stackBindings.map((b) => ({ ...b })) }
1405
- : {}),
1406
- }
1407
- await this.append(this.chatsLogPath, createEvent)
1408
- await this.setChatProvider(chatId, sourceProvider)
1409
- await this.setPlanMode(chatId, sourceChat.planMode)
1410
- await this.setPendingForkSessionToken(chatId, { provider: sourceProvider, token: sourceSessionToken })
1411
-
1412
- const sourceEntries = this.getMessages(sourceChatId)
1413
- if (sourceEntries.length > 0) {
1414
- const transcriptPath = this.transcriptPath(chatId)
1415
- const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
1416
- this.writeChain = this.writeChain.then(async () => {
1417
- await this.storage.mkdir(this.transcriptsDir)
1418
- await this.storage.writeText(transcriptPath, `${payload}\n`)
1419
- const chat = this.state.chatsById.get(chatId)
1420
- if (chat) {
1421
- chat.hasMessages = true
1422
- chat.updatedAt = Math.max(chat.updatedAt, createdAt)
1423
- }
1424
- if (this.cachedTranscript?.chatId === chatId) {
1425
- this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(sourceEntries) }
1426
- }
1427
- })
1428
- await this.writeChain
1429
- }
1430
-
1431
- return this.state.chatsById.get(chatId)!
316
+ return EntityWrite.createChat(this.buildEntityWriteDeps(), projectId, options)
1432
317
  }
1433
318
 
1434
- async renameChat(chatId: string, title: string) {
1435
- const trimmed = title.trim()
1436
- if (!trimmed) return
1437
- const chat = this.requireChat(chatId)
1438
- if (chat.title === trimmed) return
1439
- const event: ChatEvent = {
1440
- v: STORE_VERSION,
1441
- type: "chat_renamed",
1442
- timestamp: Date.now(),
1443
- chatId,
1444
- title: trimmed,
1445
- }
1446
- await this.append(this.chatsLogPath, event)
1447
- }
319
+ async forkChat(sourceChatId: string) { return TranscriptWrite.forkChat(this.buildChatTranscriptWriteDeps(), sourceChatId) }
1448
320
 
1449
- async deleteChat(chatId: string) {
1450
- const chat = this.requireChat(chatId)
1451
- const projectId = chat.projectId
1452
- const event: ChatEvent = {
1453
- v: STORE_VERSION,
1454
- type: "chat_deleted",
1455
- timestamp: Date.now(),
1456
- chatId,
1457
- }
1458
- await this.append(this.chatsLogPath, event)
1459
- for (const [id, req] of this.state.toolRequestsById) {
1460
- if (req.chatId === chatId) {
1461
- this.state.toolRequestsById.delete(id)
1462
- }
1463
- }
1464
- await this.removeSubagentResultsDir(projectId, chatId)
1465
- }
321
+ async renameChat(chatId: string, title: string) { return EntityWrite.renameChat(this.buildEntityWriteDeps(), chatId, title) }
1466
322
 
1467
- private async removeSubagentResultsDir(projectId: string, chatId: string) {
1468
- const dir = path.join(
1469
- this.dataDir, "projects", projectId, "chats", chatId, "subagent-results",
1470
- )
1471
- try {
1472
- await this.storage.remove(dir, { recursive: true })
1473
- } catch (err) {
1474
- log.warn(`${LOG_PREFIX} subagent-results cleanup failed`, { chatId, err })
1475
- }
1476
- }
323
+ async deleteChat(chatId: string) { return TranscriptWrite.deleteChat(this.buildChatTranscriptWriteDeps(), chatId) }
1477
324
 
1478
- async archiveChat(chatId: string) {
1479
- this.requireChat(chatId)
1480
- const event: ChatEvent = {
1481
- v: STORE_VERSION,
1482
- type: "chat_archived",
1483
- timestamp: Date.now(),
1484
- chatId,
1485
- }
1486
- await this.append(this.chatsLogPath, event)
1487
- }
325
+ async archiveChat(chatId: string) { return TranscriptWrite.archiveChat(this.buildChatTranscriptWriteDeps(), chatId) }
1488
326
 
1489
- async unarchiveChat(chatId: string) {
1490
- this.requireChat(chatId)
1491
- const event: ChatEvent = {
1492
- v: STORE_VERSION,
1493
- type: "chat_unarchived",
1494
- timestamp: Date.now(),
1495
- chatId,
1496
- }
1497
- await this.append(this.chatsLogPath, event)
1498
- }
327
+ async unarchiveChat(chatId: string) { return TranscriptWrite.unarchiveChat(this.buildChatTranscriptWriteDeps(), chatId) }
1499
328
 
1500
329
  async pruneStaleEmptyChats(args?: {
1501
330
  now?: number
@@ -1503,352 +332,48 @@ export class EventStore implements PushEventStore {
1503
332
  activeChatIds?: Iterable<string>
1504
333
  protectedChatIds?: Iterable<string>
1505
334
  }) {
1506
- const now = args?.now ?? Date.now()
1507
- const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
1508
- const protectedChatIds = new Set([
1509
- ...(args?.activeChatIds ?? []),
1510
- ...(args?.protectedChatIds ?? []),
1511
- ])
1512
- const prunedChatIds: string[] = []
1513
-
1514
- for (const chat of this.state.chatsById.values()) {
1515
- if (chat.deletedAt || chat.archivedAt || protectedChatIds.has(chat.id)) continue
1516
- if (now - chat.createdAt < maxAgeMs) continue
1517
- if (chat.hasMessages) continue
1518
- if (this.getMessages(chat.id).length > 0) {
1519
- chat.hasMessages = true
1520
- continue
1521
- }
1522
-
1523
- const event: ChatEvent = {
1524
- v: STORE_VERSION,
1525
- type: "chat_deleted",
1526
- timestamp: now,
1527
- chatId: chat.id,
1528
- }
1529
- await this.append(this.chatsLogPath, event)
1530
-
1531
- const transcriptPath = this.transcriptPath(chat.id)
1532
- await this.storage.remove(transcriptPath)
1533
- if (this.cachedTranscript?.chatId === chat.id) {
1534
- this.cachedTranscript = null
1535
- }
1536
- await this.removeSubagentResultsDir(chat.projectId, chat.id)
1537
-
1538
- prunedChatIds.push(chat.id)
1539
- }
1540
-
1541
- return prunedChatIds
335
+ return TranscriptWrite.pruneStaleEmptyChats(this.buildChatTranscriptWriteDeps(), args)
1542
336
  }
1543
337
 
1544
- async setChatProvider(chatId: string, provider: AgentProvider) {
1545
- const chat = this.requireChat(chatId)
1546
- if (chat.provider === provider) return
1547
- const event: ChatEvent = {
1548
- v: STORE_VERSION,
1549
- type: "chat_provider_set",
1550
- timestamp: Date.now(),
1551
- chatId,
1552
- provider,
1553
- }
1554
- await this.append(this.chatsLogPath, event)
1555
- }
338
+ async setChatProvider(chatId: string, provider: AgentProvider) { return EntityWrite.setChatProvider(this.buildEntityWriteDeps(), chatId, provider) }
1556
339
 
1557
- async setPlanMode(chatId: string, planMode: boolean) {
1558
- const chat = this.requireChat(chatId)
1559
- if (chat.planMode === planMode) return
1560
- const event: ChatEvent = {
1561
- v: STORE_VERSION,
1562
- type: "chat_plan_mode_set",
1563
- timestamp: Date.now(),
1564
- chatId,
1565
- planMode,
1566
- }
1567
- await this.append(this.chatsLogPath, event)
1568
- }
340
+ async setPlanMode(chatId: string, planMode: boolean) { return EntityWrite.setPlanMode(this.buildEntityWriteDeps(), chatId, planMode) }
1569
341
 
1570
- async setCompactFailureCount(chatId: string, compactFailureCount: number) {
1571
- const chat = this.requireChat(chatId)
1572
- if ((chat.compactFailureCount ?? 0) === compactFailureCount) return
1573
- const event: ChatEvent = {
1574
- v: STORE_VERSION,
1575
- type: "chat_compact_failures_set",
1576
- timestamp: Date.now(),
1577
- chatId,
1578
- compactFailureCount,
1579
- }
1580
- await this.append(this.chatsLogPath, event)
1581
- }
342
+ async setCompactFailureCount(chatId: string, compactFailureCount: number) { return EntityWrite.setCompactFailureCount(this.buildEntityWriteDeps(), chatId, compactFailureCount) }
1582
343
 
1583
- async setChatReadState(chatId: string, unread: boolean) {
1584
- const chat = this.requireChat(chatId)
1585
- if (chat.unread === unread) return
1586
- const event: ChatEvent = {
1587
- v: STORE_VERSION,
1588
- type: "chat_read_state_set",
1589
- timestamp: Date.now(),
1590
- chatId,
1591
- unread,
1592
- }
1593
- await this.append(this.chatsLogPath, event)
1594
- }
344
+ async setChatReadState(chatId: string, unread: boolean) { return EntityWrite.setChatReadState(this.buildEntityWriteDeps(), chatId, unread) }
1595
345
 
1596
- async setChatPolicyOverride(chatId: string, policyOverride: ChatPermissionPolicyOverride | null) {
1597
- this.requireChat(chatId)
1598
- const event: ChatEvent = {
1599
- v: STORE_VERSION,
1600
- type: "chat_policy_override_set",
1601
- timestamp: Date.now(),
1602
- chatId,
1603
- policyOverride,
1604
- }
1605
- await this.append(this.chatsLogPath, event)
1606
- }
346
+ async setChatPolicyOverride(chatId: string, policyOverride: ChatPermissionPolicyOverride | null) { return EntityWrite.setChatPolicyOverride(this.buildEntityWriteDeps(), chatId, policyOverride) }
1607
347
 
1608
- async appendMessage(chatId: string, entry: TranscriptEntry) {
1609
- this.requireChat(chatId)
1610
- const payload = `${JSON.stringify(entry)}\n`
1611
- const transcriptPath = this.transcriptPath(chatId)
1612
- const queuedAt = performance.now()
1613
- this.writeChain = this.writeChain.then(async () => {
1614
- const startedAt = performance.now()
1615
- const queueDelayMs = Number((startedAt - queuedAt).toFixed(1))
1616
- // Dedupe by messageId: if a transcript entry from the same JSONL source
1617
- // message has already been appended, skip. Server-generated entries
1618
- // without messageId (e.g. interrupted, context_cleared) always append.
1619
- const mid = entry.messageId
1620
- if (typeof mid === "string" && mid.length > 0) {
1621
- // Ensure the transcript is loaded so the seen set is populated.
1622
- this.getMessages(chatId)
1623
- const seen = this.getSeenMessageIds(chatId)
1624
- if (seen.has(mid)) {
1625
- logSendToStartingProfile("event_store.append_message_dedup", {
1626
- chatId,
1627
- messageId: mid,
1628
- kind: entry.kind,
1629
- })
1630
- return
1631
- }
1632
- seen.add(mid)
1633
- }
1634
- await this.storage.mkdir(this.transcriptsDir)
1635
- const beforeAppendAt = performance.now()
1636
- await this.storage.appendText(transcriptPath, payload)
1637
- const afterAppendAt = performance.now()
1638
- this.applyMessageMetadata(chatId, entry)
1639
- if (this.cachedTranscript?.chatId === chatId) {
1640
- this.cachedTranscript.entries.push({ ...entry })
1641
- }
1642
- logSendToStartingProfile("event_store.append_message", {
1643
- chatId,
1644
- entryId: entry._id,
1645
- kind: entry.kind,
1646
- payloadBytes: payload.length,
1647
- queueDelayMs,
1648
- appendMs: Number((afterAppendAt - beforeAppendAt).toFixed(1)),
1649
- totalMs: Number((afterAppendAt - queuedAt).toFixed(1)),
1650
- })
1651
- })
1652
- return this.writeChain
1653
- }
348
+ async appendMessage(chatId: string, entry: TranscriptEntry) { return TranscriptWrite.appendMessage(this.buildChatTranscriptWriteDeps(), chatId, entry) }
1654
349
 
1655
- async enqueueMessage(chatId: string, message: Omit<QueuedChatMessage, "id" | "createdAt"> & Partial<Pick<QueuedChatMessage, "id" | "createdAt">>) {
1656
- this.requireChat(chatId)
1657
- const queuedMessage: QueuedChatMessage = {
1658
- id: message.id ?? crypto.randomUUID(),
1659
- content: message.content,
1660
- attachments: [...(message.attachments ?? [])],
1661
- createdAt: message.createdAt ?? Date.now(),
1662
- provider: message.provider,
1663
- model: message.model,
1664
- modelOptions: message.modelOptions,
1665
- planMode: message.planMode,
1666
- autoContinue: message.autoContinue,
1667
- }
1668
- const event: QueuedMessageEvent = {
1669
- v: STORE_VERSION,
1670
- type: "queued_message_enqueued",
1671
- timestamp: queuedMessage.createdAt,
1672
- chatId,
1673
- message: queuedMessage,
1674
- }
1675
- await this.append(this.queuedMessagesLogPath, event)
1676
- return queuedMessage
1677
- }
350
+ async enqueueMessage(chatId: string, message: Omit<QueuedChatMessage, "id" | "createdAt"> & Partial<Pick<QueuedChatMessage, "id" | "createdAt">>) { return EntityWrite.enqueueMessage(this.buildEntityWriteDeps(), chatId, message) }
1678
351
 
1679
- async removeQueuedMessage(chatId: string, queuedMessageId: string) {
1680
- this.requireChat(chatId)
1681
- const existing = this.getQueuedMessages(chatId)
1682
- if (!existing.some((entry) => entry.id === queuedMessageId)) {
1683
- throw new Error("Queued message not found")
1684
- }
1685
- const event: QueuedMessageEvent = {
1686
- v: STORE_VERSION,
1687
- type: "queued_message_removed",
1688
- timestamp: Date.now(),
1689
- chatId,
1690
- queuedMessageId,
1691
- }
1692
- await this.append(this.queuedMessagesLogPath, event)
1693
- }
352
+ async removeQueuedMessage(chatId: string, queuedMessageId: string) { return EntityWrite.removeQueuedMessage(this.buildEntityWriteDeps(), chatId, queuedMessageId) }
1694
353
 
1695
- async recordTurnStarted(chatId: string, runConfig?: TurnRunConfig) {
1696
- this.requireChat(chatId)
1697
- const event: TurnEvent = {
1698
- v: STORE_VERSION,
1699
- type: "turn_started",
1700
- timestamp: Date.now(),
1701
- chatId,
1702
- ...(runConfig ? { runConfig } : {}),
1703
- }
1704
- await this.append(this.turnsLogPath, event)
1705
- }
354
+ async recordTurnStarted(chatId: string, runConfig?: TurnRunConfig) { return EntityWrite.recordTurnStarted(this.buildSessionWriteDeps(), chatId, runConfig) }
1706
355
 
1707
- async recordTurnFinished(chatId: string) {
1708
- this.requireChat(chatId)
1709
- const event: TurnEvent = {
1710
- v: STORE_VERSION,
1711
- type: "turn_finished",
1712
- timestamp: Date.now(),
1713
- chatId,
1714
- }
1715
- await this.append(this.turnsLogPath, event)
1716
- }
356
+ async recordTurnFinished(chatId: string) { return EntityWrite.recordTurnFinished(this.buildSessionWriteDeps(), chatId) }
1717
357
 
1718
- async recordTurnFailed(chatId: string, error: string) {
1719
- this.requireChat(chatId)
1720
- const event: TurnEvent = {
1721
- v: STORE_VERSION,
1722
- type: "turn_failed",
1723
- timestamp: Date.now(),
1724
- chatId,
1725
- error,
1726
- }
1727
- await this.append(this.turnsLogPath, event)
1728
- }
358
+ async recordTurnFailed(chatId: string, error: string) { return EntityWrite.recordTurnFailed(this.buildSessionWriteDeps(), chatId, error) }
1729
359
 
1730
- async recordTurnCancelled(chatId: string) {
1731
- this.requireChat(chatId)
1732
- const event: TurnEvent = {
1733
- v: STORE_VERSION,
1734
- type: "turn_cancelled",
1735
- timestamp: Date.now(),
1736
- chatId,
1737
- }
1738
- await this.append(this.turnsLogPath, event)
1739
- }
360
+ async recordTurnCancelled(chatId: string) { return EntityWrite.recordTurnCancelled(this.buildSessionWriteDeps(), chatId) }
1740
361
 
1741
- async appendSubagentEvent(event: SubagentRunEvent) {
1742
- let effectiveEvent = event
1743
- if (event.type === "subagent_entry_appended" && event.entry.kind === "tool_result") {
1744
- const chat = this.state.chatsById.get(event.chatId)
1745
- if (chat) {
1746
- effectiveEvent = {
1747
- ...event,
1748
- entry: await capTranscriptEntry({
1749
- entry: event.entry,
1750
- chatId: event.chatId,
1751
- runId: event.runId,
1752
- projectId: chat.projectId,
1753
- kannaRoot: this.dataDir,
1754
- }),
1755
- }
1756
- }
1757
- }
1758
- // Apply in-memory synchronously so the UI sees the update immediately,
1759
- // decoupled from disk I/O backlog on writeChain (scoped to ephemeral
1760
- // subagent_* events only — structural events keep strict append→apply ordering).
1761
- this.applyEvent(effectiveEvent)
1762
- this.enqueueDiskAppend(this.turnsLogPath, `${JSON.stringify(effectiveEvent)}\n`)
1763
- }
362
+ async appendSubagentEvent(event: SubagentRunEvent) { return appendSubagentEventFn(this.buildAppendSubagentDeps(), event) }
1764
363
 
1765
- getSubagentRuns(chatId: string): Record<string, SubagentRunSnapshot> {
1766
- const map = this.state.subagentRunsByChatId.get(chatId)
1767
- if (!map) return {}
1768
- return Object.fromEntries(map.entries())
1769
- }
364
+ getSubagentRuns(chatId: string): Record<string, SubagentRunSnapshot> { return getSubagentRunsFromMap(this.state.subagentRunsByChatId, chatId) }
1770
365
 
1771
- *runningSubagentRuns(): Iterable<SubagentRunSnapshot> {
1772
- for (const map of this.state.subagentRunsByChatId.values()) {
1773
- for (const run of map.values()) {
1774
- if (run.status === "running") yield run
1775
- }
1776
- }
1777
- }
366
+ *runningSubagentRuns(): Iterable<SubagentRunSnapshot> { yield* runningSubagentRunsFromMap(this.state.subagentRunsByChatId) }
1778
367
 
1779
- async setSessionTokenForProvider(
1780
- chatId: string,
1781
- provider: AgentProvider,
1782
- sessionToken: string | null,
1783
- ) {
1784
- const chat = this.requireChat(chatId)
1785
- if ((chat.sessionTokensByProvider[provider] ?? null) === sessionToken) return
1786
- const event: TurnEvent = {
1787
- v: STORE_VERSION,
1788
- type: "session_token_set",
1789
- timestamp: Date.now(),
1790
- chatId,
1791
- sessionToken,
1792
- provider,
1793
- }
1794
- await this.append(this.turnsLogPath, event)
1795
- }
368
+ async setSessionTokenForProvider(chatId: string, provider: AgentProvider, sessionToken: string | null) { return EntityWrite.setSessionTokenForProvider(this.buildSessionWriteDeps(), chatId, provider, sessionToken) }
1796
369
 
1797
- async recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]) {
1798
- const chat = this.requireChat(chatId)
1799
- const normalized = commands.map((c) => ({
1800
- name: c.name,
1801
- description: c.description,
1802
- argumentHint: c.argumentHint,
1803
- }))
1804
- if (chat.slashCommands && slashCommandsEqual(chat.slashCommands, normalized)) {
1805
- return
1806
- }
1807
- const event: TurnEvent = {
1808
- v: STORE_VERSION,
1809
- type: "session_commands_loaded",
1810
- timestamp: Date.now(),
1811
- chatId,
1812
- commands: normalized,
1813
- }
1814
- await this.append(this.turnsLogPath, event)
1815
- }
370
+ async recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]) { return EntityWrite.recordSessionCommandsLoaded(this.buildSessionWriteDeps(), chatId, commands) }
1816
371
 
1817
- async setPendingForkSessionToken(
1818
- chatId: string,
1819
- value: { provider: AgentProvider; token: string } | null,
1820
- ) {
1821
- const chat = this.requireChat(chatId)
1822
- const current = chat.pendingForkSessionToken ?? null
1823
- const same =
1824
- (current == null && value == null)
1825
- || (current != null && value != null
1826
- && current.provider === value.provider
1827
- && current.token === value.token)
1828
- if (same) return
1829
- const event: TurnEvent = {
1830
- v: STORE_VERSION,
1831
- type: "pending_fork_session_token_set",
1832
- timestamp: Date.now(),
1833
- chatId,
1834
- pendingForkSessionToken: value?.token ?? null,
1835
- provider: value?.provider,
1836
- }
1837
- await this.append(this.turnsLogPath, event)
372
+ async setPendingForkSessionToken(chatId: string, value: { provider: AgentProvider; token: string } | null) {
373
+ return EntityWrite.setPendingForkSessionToken(this.buildSessionWriteDeps(), chatId, value)
1838
374
  }
1839
375
 
1840
- async setSourceHash(chatId: string, sourceHash: string | null) {
1841
- const chat = this.requireChat(chatId)
1842
- if (chat.sourceHash === sourceHash) return
1843
- const event: ChatEvent = {
1844
- v: STORE_VERSION,
1845
- type: "chat_source_hash_set",
1846
- timestamp: Date.now(),
1847
- chatId,
1848
- sourceHash,
1849
- }
1850
- await this.append(this.chatsLogPath, event)
1851
- }
376
+ async setSourceHash(chatId: string, sourceHash: string | null) { return EntityWrite.setSourceHash(this.buildEntityWriteDeps(), chatId, sourceHash) }
1852
377
 
1853
378
  getProject(projectId: string) {
1854
379
  const project = this.state.projectsById.get(projectId)
@@ -1870,110 +395,23 @@ export class EventStore implements PushEventStore {
1870
395
  return chat
1871
396
  }
1872
397
 
1873
- getSidebarProjectOrder() {
1874
- return [...this.sidebarProjectOrder]
1875
- }
398
+ getSidebarProjectOrder() { return [...this.sidebarProjectOrderRef.value] }
1876
399
 
1877
- private getMessagesPageFromEntries(entries: TranscriptEntry[], limit: number, beforeIndex?: number): TranscriptPageResult {
1878
- if (entries.length === 0) {
1879
- return { entries: [], hasOlder: false, olderCursor: null }
1880
- }
400
+ // ─── Message read methods (thin delegates) ────────────────────────────────
1881
401
 
1882
- const endIndex = beforeIndex === undefined ? entries.length : Math.max(0, Math.min(beforeIndex, entries.length))
1883
- const startIndex = Math.max(0, endIndex - limit)
1884
- return {
1885
- entries: cloneTranscriptEntries(entries.slice(startIndex, endIndex)),
1886
- hasOlder: startIndex > 0,
1887
- olderCursor: startIndex > 0 ? encodeHistoryCursor(startIndex) : null,
1888
- }
1889
- }
402
+ getMessages(chatId: string) { return MessageRead.getMessages(this.buildMessageReadDeps(), chatId) }
1890
403
 
1891
- getMessages(chatId: string) {
1892
- if (this.cachedTranscript?.chatId === chatId) {
1893
- return cloneTranscriptEntries(this.cachedTranscript.entries)
1894
- }
1895
-
1896
- const legacyEntries = this.legacyMessagesByChatId.get(chatId)
1897
- if (legacyEntries) {
1898
- this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(legacyEntries) }
1899
- return cloneTranscriptEntries(this.cachedTranscript.entries)
1900
- }
1901
-
1902
- const entries = this.loadTranscriptFromDisk(chatId)
1903
- this.cachedTranscript = { chatId, entries }
1904
- return cloneTranscriptEntries(entries)
1905
- }
1906
-
1907
- getQueuedMessages(chatId: string) {
1908
- const entries = this.state.queuedMessagesByChatId.get(chatId) ?? []
1909
- return entries.map((entry) => ({
1910
- ...entry,
1911
- attachments: [...entry.attachments],
1912
- }))
1913
- }
1914
-
1915
- getQueuedMessage(chatId: string, queuedMessageId: string) {
1916
- return this.getQueuedMessages(chatId).find((entry) => entry.id === queuedMessageId) ?? null
1917
- }
1918
-
1919
- getRecentMessagesPage(chatId: string, limit: number): ChatHistoryPage {
1920
- if (limit <= 0) {
1921
- return { messages: [], hasOlder: false, olderCursor: null }
1922
- }
404
+ getQueuedMessages(chatId: string) { return MessageRead.getQueuedMessages(this.buildMessageReadDeps(), chatId) }
1923
405
 
1924
- const entries = coalesceContextWindowUpdates(this.getMessages(chatId))
1925
- const page = this.getMessagesPageFromEntries(entries, limit)
406
+ getQueuedMessage(chatId: string, queuedMessageId: string) { return MessageRead.getQueuedMessage(this.buildMessageReadDeps(), chatId, queuedMessageId) }
1926
407
 
1927
- return {
1928
- messages: page.entries,
1929
- hasOlder: page.hasOlder,
1930
- olderCursor: page.olderCursor,
1931
- }
1932
- }
408
+ getRecentMessagesPage(chatId: string, limit: number): ChatHistoryPage { return MessageRead.getRecentMessagesPage(this.buildMessageReadDeps(), chatId, limit) }
1933
409
 
1934
- getMessagesPageBefore(chatId: string, beforeCursor: string, limit: number): ChatHistoryPage {
1935
- if (limit <= 0) {
1936
- return { messages: [], hasOlder: false, olderCursor: null }
1937
- }
410
+ getMessagesPageBefore(chatId: string, beforeCursor: string, limit: number): ChatHistoryPage { return MessageRead.getMessagesPageBefore(this.buildMessageReadDeps(), chatId, beforeCursor, limit) }
1938
411
 
1939
- const beforeIndex = decodeCursor(beforeCursor)
1940
- // Coalesce identically to getRecentMessagesPage so cursors (which index the
1941
- // coalesced array) stay consistent across recent + load-older paging.
1942
- const entries = coalesceContextWindowUpdates(this.getMessages(chatId))
1943
- const page = this.getMessagesPageFromEntries(entries, limit, beforeIndex)
412
+ getRecentChatHistory(chatId: string, recentLimit: number) { return MessageRead.getRecentChatHistory(this.buildMessageReadDeps(), chatId, recentLimit) }
1944
413
 
1945
- return {
1946
- messages: page.entries,
1947
- hasOlder: page.hasOlder,
1948
- olderCursor: page.olderCursor,
1949
- }
1950
- }
1951
-
1952
- getRecentChatHistory(chatId: string, recentLimit: number) {
1953
- const page = this.getRecentMessagesPage(chatId, recentLimit)
1954
- const pending = this.listPendingToolRequests(chatId)
1955
- const pendingEntries: TranscriptEntry[] = pending.map((req) => ({
1956
- _id: `pending-tool-request-${req.id}`,
1957
- createdAt: req.createdAt,
1958
- kind: "pending_tool_request",
1959
- toolRequestId: req.id,
1960
- toolName: req.toolName,
1961
- arguments: req.arguments,
1962
- }))
1963
- const merged = [...page.messages, ...pendingEntries]
1964
- return {
1965
- messages: merged,
1966
- history: getHistorySnapshot({
1967
- entries: merged,
1968
- hasOlder: page.hasOlder,
1969
- olderCursor: page.olderCursor,
1970
- }, recentLimit),
1971
- }
1972
- }
1973
-
1974
- listProjects() {
1975
- return [...this.state.projectsById.values()].filter((project) => !project.deletedAt)
1976
- }
414
+ listProjects() { return [...this.state.projectsById.values()].filter((project) => !project.deletedAt) }
1977
415
 
1978
416
  listChatsByProject(projectId: string) {
1979
417
  return [...this.state.chatsById.values()]
@@ -1981,556 +419,85 @@ export class EventStore implements PushEventStore {
1981
419
  .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
1982
420
  }
1983
421
 
1984
- getChatCount(projectId: string) {
1985
- return this.listChatsByProject(projectId).length
1986
- }
422
+ getChatCount(projectId: string) { return MessageRead.getChatCount(this.buildMessageReadDeps(), projectId) }
1987
423
 
1988
- async getLegacyTranscriptStats(): Promise<LegacyTranscriptStats> {
1989
- const messagesLogSize = await this.storage.size(this.messagesLogPath)
1990
- const sources: LegacyTranscriptStats["sources"] = []
1991
- if (this.snapshotHasLegacyMessages) {
1992
- sources.push("snapshot")
1993
- }
1994
- if (messagesLogSize > 0) {
1995
- sources.push("messages_log")
1996
- }
424
+ async getLegacyTranscriptStats(): Promise<LegacyTranscriptStats> { return getLegacyTranscriptStatsFn(this.buildInitDeps()) }
1997
425
 
1998
- let entryCount = 0
1999
- for (const entries of this.legacyMessagesByChatId.values()) {
2000
- entryCount += entries.length
2001
- }
426
+ async hasLegacyTranscriptData() { return hasLegacyTranscriptDataFn(this.buildInitDeps()) }
2002
427
 
2003
- return {
2004
- hasLegacyData: sources.length > 0 || this.legacyMessagesByChatId.size > 0,
2005
- sources,
2006
- chatCount: this.legacyMessagesByChatId.size,
2007
- entryCount,
2008
- }
2009
- }
428
+ async snapshotAndTruncateLogs() { return snapshotAndTruncateLogsFn(this.buildInitDeps()) }
2010
429
 
2011
- async hasLegacyTranscriptData() {
2012
- return (await this.getLegacyTranscriptStats()).hasLegacyData
2013
- }
2014
-
2015
- private createSnapshot(): SnapshotFile {
2016
- return {
2017
- v: STORE_VERSION,
2018
- generatedAt: Date.now(),
2019
- projects: this.listProjects().map((project) => ({ ...project })),
2020
- chats: [...this.state.chatsById.values()]
2021
- .filter((chat) => !chat.deletedAt)
2022
- .map((chat) => ({ ...chat })),
2023
- queuedMessages: [...this.state.queuedMessagesByChatId.entries()]
2024
- .map(([chatId, entries]) => ({
2025
- chatId,
2026
- entries: entries.map((entry) => ({
2027
- ...entry,
2028
- attachments: [...entry.attachments],
2029
- })),
2030
- })),
2031
- autoContinueEvents: [...this.state.autoContinueEventsByChatId.entries()].map(([chatId, events]) => ({
2032
- chatId,
2033
- events: [...events],
2034
- })),
2035
- stacks: [...this.state.stacksById.values()]
2036
- .filter((stack) => !stack.deletedAt)
2037
- .map((stack) => ({ ...stack, projectIds: [...stack.projectIds] })),
2038
- }
2039
- }
430
+ async migrateLegacyTranscripts(onProgress?: (message: string) => void) { return migrateLegacyTranscriptsFn(this.buildInitDeps(), onProgress) }
2040
431
 
2041
- async snapshotAndTruncateLogs() {
2042
- const snapshot = this.createSnapshot()
2043
- await this.storage.writeText(this.snapshotPath, JSON.stringify(snapshot, null, 2))
2044
- await Promise.all([
2045
- this.storage.writeText(this.projectsLogPath, ""),
2046
- this.storage.writeText(this.chatsLogPath, ""),
2047
- this.storage.writeText(this.messagesLogPath, ""),
2048
- this.storage.writeText(this.queuedMessagesLogPath, ""),
2049
- this.storage.writeText(this.turnsLogPath, ""),
2050
- this.storage.writeText(this.schedulesLogPath, ""),
2051
- this.storage.writeText(this.stacksLogPath, ""),
2052
- // tunnels.jsonl is NOT compacted into the snapshot — it's left as-is
2053
- // so that active tunnel state survives server restarts.
2054
- // tool-requests.jsonl is NOT persisted to the snapshot. After compaction,
2055
- // in-memory state remains intact for the current process lifetime.
2056
- // On next server boot, tool-requests will be absent (fail-closed);
2057
- // Task 7 recoverOnStartup marks them session_closed.
2058
- this.storage.writeText(this.toolRequestsLogPath, ""),
2059
- ])
2060
- }
2061
-
2062
- async migrateLegacyTranscripts(onProgress?: (message: string) => void) {
2063
- const stats = await this.getLegacyTranscriptStats()
2064
- if (!stats.hasLegacyData) return false
2065
-
2066
- const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
2067
- onProgress?.(`${LOG_PREFIX} transcript migration detected: ${stats.chatCount} chats, ${stats.entryCount} entries from ${sourceSummary}`)
2068
-
2069
- const messageSets = [...this.legacyMessagesByChatId.entries()]
2070
- onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
2071
-
2072
- await this.storage.mkdir(this.transcriptsDir)
2073
- const logEveryChat = messageSets.length <= 10
2074
- for (let index = 0; index < messageSets.length; index += 1) {
2075
- const [chatId, entries] = messageSets[index]
2076
- const transcriptPath = this.transcriptPath(chatId)
2077
- const tempPath = `${transcriptPath}.tmp`
2078
- const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
2079
- await this.storage.writeText(tempPath, payload ? `${payload}\n` : "")
2080
- await this.storage.rename(tempPath, transcriptPath)
2081
- if (logEveryChat || (index + 1) % 25 === 0 || index === messageSets.length - 1) {
2082
- onProgress?.(`${LOG_PREFIX} transcript migration: ${index + 1}/${messageSets.length} chats`)
2083
- }
2084
- }
2085
-
2086
- this.clearLegacyTranscriptState()
2087
- await this.snapshotAndTruncateLogs()
2088
- this.cachedTranscript = null
2089
- onProgress?.(`${LOG_PREFIX} transcript migration complete`)
2090
- return true
2091
- }
2092
-
2093
- private async shouldSnapshotLogs() {
2094
- const sizes = await Promise.all([
2095
- this.storage.size(this.projectsLogPath),
2096
- this.storage.size(this.chatsLogPath),
2097
- this.storage.size(this.messagesLogPath),
2098
- this.storage.size(this.queuedMessagesLogPath),
2099
- this.storage.size(this.turnsLogPath),
2100
- this.storage.size(this.schedulesLogPath),
2101
- this.storage.size(this.stacksLogPath),
2102
- this.storage.size(this.toolRequestsLogPath),
2103
- ])
2104
- return sizes.reduce((total, size) => total + size, 0) >= SNAPSHOT_THRESHOLD_BYTES
2105
- }
2106
-
2107
- async appendAutoContinueEvent(event: AutoContinueEvent) {
2108
- return this.append(this.schedulesLogPath, event)
2109
- }
432
+ async appendAutoContinueEvent(event: AutoContinueEvent) { return EntityWrite.appendAutoContinueEvent(this.buildSessionWriteDeps(), event) }
2110
433
 
2111
434
  getAutoContinueEvents(chatId: string): AutoContinueEvent[] {
2112
435
  const list = this.state.autoContinueEventsByChatId.get(chatId)
2113
436
  return list ? [...list] : []
2114
437
  }
2115
438
 
2116
- listAutoContinueChats(): string[] {
2117
- return [...this.state.autoContinueEventsByChatId.keys()]
2118
- }
439
+ listAutoContinueChats(): string[] { return [...this.state.autoContinueEventsByChatId.keys()] }
2119
440
 
2120
- async appendTunnelEvent(event: CloudflareTunnelEvent): Promise<void> {
2121
- const payload = `${JSON.stringify(event)}\n`
2122
- this.writeChain = this.writeChain.then(async () => {
2123
- await this.storage.appendText(this.tunnelLogPath, payload)
2124
- this.applyTunnelEvent(event)
2125
- })
2126
- await this.writeChain
2127
- }
441
+ // ─── Peripheral event methods (thin delegates) ───────────────────────────
2128
442
 
2129
- getTunnelEvents(chatId: string): CloudflareTunnelEvent[] {
2130
- const list = this.tunnelEventsByChatId.get(chatId)
2131
- return list ? [...list] : []
2132
- }
443
+ async appendTunnelEvent(event: CloudflareTunnelEvent): Promise<void> { return PeripheralEvents.appendTunnelEvent(this.buildPeripheralEventsDeps(), event) }
2133
444
 
2134
- listTunnelChats(): string[] {
2135
- return [...this.tunnelEventsByChatId.keys()]
2136
- }
445
+ getTunnelEvents(chatId: string): CloudflareTunnelEvent[] { return PeripheralEvents.getTunnelEvents(this.buildPeripheralEventsDeps(), chatId) }
2137
446
 
2138
- private applyTunnelEvent(event: CloudflareTunnelEvent): void {
2139
- const existing = this.tunnelEventsByChatId.get(event.chatId) ?? []
2140
- existing.push(event)
2141
- this.tunnelEventsByChatId.set(event.chatId, existing)
2142
- }
447
+ listTunnelChats(): string[] { return PeripheralEvents.listTunnelChats(this.buildPeripheralEventsDeps()) }
2143
448
 
2144
- private async loadTunnelEvents(): Promise<void> {
2145
- if (!(await this.storage.exists(this.tunnelLogPath))) return
2146
- const text = await this.storage.readText(this.tunnelLogPath)
2147
- if (!text.trim()) return
2148
-
2149
- for (const rawLine of text.split("\n")) {
2150
- const line = rawLine.trim()
2151
- if (!line) continue
2152
- try {
2153
- const event: CloudflareTunnelEvent = JSON.parse(line)
2154
- this.applyTunnelEvent(event)
2155
- } catch {
2156
- log.warn(`${LOG_PREFIX} Ignoring malformed line in tunnels.jsonl`)
2157
- }
2158
- }
2159
- }
449
+ private async loadTunnelEvents(): Promise<void> { await PeripheralEvents.loadTunnelEvents(this.buildPeripheralEventsDeps()) }
2160
450
 
2161
- async appendShareEvent(event: ShareEvent): Promise<void> {
2162
- const payload = `${JSON.stringify(event)}\n`
2163
- this.writeChain = this.writeChain.then(async () => {
2164
- await this.storage.appendText(this.sharesLogPath, payload)
2165
- this.shareEventsAll.push(event)
2166
- })
2167
- await this.writeChain
2168
- }
451
+ async appendShareEvent(event: ShareEvent): Promise<void> { return PeripheralEvents.appendShareEvent(this.buildPeripheralEventsDeps(), event) }
2169
452
 
2170
- getShareEvents(): ShareEvent[] {
2171
- return [...this.shareEventsAll]
2172
- }
453
+ getShareEvents(): ShareEvent[] { return PeripheralEvents.getShareEvents(this.buildPeripheralEventsDeps()) }
2173
454
 
2174
- private async loadShareEvents(): Promise<void> {
2175
- if (!(await this.storage.exists(this.sharesLogPath))) return
2176
- const text = await this.storage.readText(this.sharesLogPath)
2177
- if (!text.trim()) return
2178
- for (const rawLine of text.split("\n")) {
2179
- const line = rawLine.trim()
2180
- if (!line) continue
2181
- try {
2182
- const event: ShareEvent = JSON.parse(line)
2183
- this.shareEventsAll.push(event)
2184
- } catch {
2185
- log.warn(`${LOG_PREFIX} Ignoring malformed line in shares.jsonl`)
2186
- }
2187
- }
2188
- }
455
+ private async loadShareEvents(): Promise<void> { await PeripheralEvents.loadShareEvents(this.buildPeripheralEventsDeps()) }
2189
456
 
2190
- async appendPushEvent(event: PushEvent): Promise<void> {
2191
- const payload = `${JSON.stringify(event)}\n`
2192
- this.writeChain = this.writeChain.then(async () => {
2193
- await this.storage.appendText(this.pushLogPath, payload)
2194
- })
2195
- await this.writeChain
2196
- }
457
+ async appendPushEvent(event: PushEvent): Promise<void> { return PeripheralEvents.appendPushEvent(this.buildPeripheralEventsDeps(), event) }
2197
458
 
2198
- async loadPushEvents(): Promise<PushEvent[]> {
2199
- if (!(await this.storage.exists(this.pushLogPath))) return []
2200
- const text = await this.storage.readText(this.pushLogPath)
2201
- if (!text.trim()) return []
2202
-
2203
- const events: PushEvent[] = []
2204
- for (const rawLine of text.split("\n")) {
2205
- const line = rawLine.trim()
2206
- if (!line) continue
2207
- try {
2208
- const pushEvent: PushEvent = JSON.parse(line)
2209
- events.push(pushEvent)
2210
- } catch {
2211
- log.warn(`${LOG_PREFIX} Ignoring malformed line in push.jsonl`)
2212
- }
2213
- }
2214
- return events
2215
- }
459
+ async loadPushEvents(): Promise<PushEvent[]> { return PeripheralEvents.loadPushEvents(this.buildPeripheralEventsDeps()) }
2216
460
 
2217
- async putToolRequest(req: ToolRequest): Promise<void> {
2218
- this.state.toolRequestsById.set(req.id, { ...req })
2219
- await this.append(this.toolRequestsLogPath, {
2220
- v: 3,
2221
- type: "tool_request_put",
2222
- timestamp: Date.now(),
2223
- request: req,
2224
- } satisfies ToolRequestEvent)
2225
- }
461
+ async putToolRequest(req: ToolRequest): Promise<void> { return putToolRequestFn(this.buildToolRequestWriteDeps(), req) }
2226
462
 
2227
- getToolRequest(id: string): ToolRequest | null {
2228
- const req = this.state.toolRequestsById.get(id)
2229
- return req ? { ...req } : null
2230
- }
463
+ getToolRequest(id: string): ToolRequest | null { return getToolRequestFromMap(this.state.toolRequestsById, id) }
2231
464
 
2232
- listPendingToolRequests(chatId: string): ToolRequest[] {
2233
- const out: ToolRequest[] = []
2234
- for (const req of this.state.toolRequestsById.values()) {
2235
- if (req.chatId !== chatId) continue
2236
- if (req.status !== "pending") continue
2237
- out.push({ ...req })
2238
- }
2239
- return out
2240
- }
465
+ listPendingToolRequests(chatId: string): ToolRequest[] { return listPendingToolRequestsFromMap(this.state.toolRequestsById, chatId) }
2241
466
 
2242
467
  async resolveToolRequest(
2243
468
  id: string,
2244
- args: {
2245
- status: ToolRequestStatus
2246
- decision?: ToolRequestDecision
2247
- resolvedAt: number
2248
- mismatchReason?: string
2249
- },
469
+ args: { status: ToolRequestStatus; decision?: ToolRequestDecision; resolvedAt: number; mismatchReason?: string },
2250
470
  ): Promise<void> {
2251
- const existing = this.state.toolRequestsById.get(id)
2252
- if (!existing) throw new Error(`resolveToolRequest: unknown id ${id}`)
2253
- const next: ToolRequest = {
2254
- ...existing,
2255
- status: args.status,
2256
- decision: args.decision ?? existing.decision,
2257
- resolvedAt: args.resolvedAt,
2258
- mismatchReason: args.mismatchReason,
2259
- }
2260
- this.state.toolRequestsById.set(id, next)
2261
- await this.append(this.toolRequestsLogPath, {
2262
- v: 3,
2263
- type: "tool_request_resolved",
2264
- timestamp: Date.now(),
2265
- id,
2266
- status: args.status,
2267
- decision: args.decision,
2268
- resolvedAt: args.resolvedAt,
2269
- mismatchReason: args.mismatchReason,
2270
- } satisfies ToolRequestEvent)
2271
- }
2272
-
2273
- scanAllToolRequests(): ToolRequest[] {
2274
- return [...this.state.toolRequestsById.values()].map((req) => ({ ...req }))
471
+ return resolveToolRequestFn(this.buildToolRequestWriteDeps(), id, args)
2275
472
  }
2276
473
 
2277
- async flush(): Promise<void> {
2278
- await this.writeChain
2279
- }
2280
-
2281
- private readonly orchRunsSubscribers = new Set<() => void>()
2282
-
2283
- private applyOrchestrationEvent(event: OrchestrationEvent) {
2284
- if (event.type === "orch_run_created") {
2285
- const tasksById = new Map<string, OrchTaskRecord>()
2286
- for (const spec of event.tasks) {
2287
- tasksById.set(spec.id, {
2288
- taskId: spec.id,
2289
- title: spec.title,
2290
- prompt: spec.prompt,
2291
- scopePaths: spec.scopePaths ?? [],
2292
- state: "queued",
2293
- ownerWorkerId: null,
2294
- worktreePath: null,
2295
- branch: null,
2296
- baseSha: null,
2297
- phaseIndex: -1,
2298
- attempts: 0,
2299
- error: null,
2300
- commitSha: null,
2301
- lastPhaseOutput: null,
2302
- verifying: false,
2303
- updatedAt: event.timestamp,
2304
- })
2305
- }
2306
- this.state.orchRunsById.set(event.runId, {
2307
- runId: event.runId,
2308
- status: "running",
2309
- config: event.config,
2310
- tasksById,
2311
- taskOrder: event.tasks.map((t) => t.id),
2312
- worktrees: [],
2313
- eventLog: [event],
2314
- createdAt: event.timestamp,
2315
- updatedAt: event.timestamp,
2316
- })
2317
- return
2318
- }
2319
- const run = this.state.orchRunsById.get(event.runId)
2320
- if (!run) return
2321
- run.eventLog.push(event)
2322
- run.updatedAt = event.timestamp
2323
- if (event.type === "orch_run_completed" || event.type === "orch_run_cancelled") {
2324
- const trans = nextRunStatus(run.status, event.type)
2325
- if (!trans.ok) {
2326
- log.warn(`${LOG_PREFIX} dropped illegal orch run transition`, { runId: event.runId, from: run.status, event: event.type })
2327
- return
2328
- }
2329
- run.status = trans.next
2330
- return
2331
- }
2332
- if (event.type === "orch_scope_overlap_flagged") return // observability-only, no state fold
2333
- if (event.type === "orch_config_warning") return // observability-only, no state fold
2334
- // Worktree pool fold (F13)
2335
- if (event.type === "orch_worktree_provisioned") {
2336
- run.worktrees.push({
2337
- index: event.index, path: event.path, branch: event.branch,
2338
- heldByTaskId: null, initialized: false,
2339
- })
2340
- return
2341
- }
2342
- if (event.type === "orch_worktree_init_started") return // timeline-only
2343
- if (event.type === "orch_worktree_init_completed") {
2344
- const slot = run.worktrees.find((w) => w.index === event.index)
2345
- if (slot) slot.initialized = event.ok
2346
- return
2347
- }
2348
- const task = run.tasksById.get(event.taskId)
2349
- if (!task) return
2350
- // FSM guard: drop an illegal task transition rather than corrupt the read
2351
- // model. The engine only emits legal transitions, so this is a defensive
2352
- // invariant (also protects replay of an older/foreign log).
2353
- if (isTaskEventType(event.type)) {
2354
- const trans = nextTaskState(task.state, event.type)
2355
- if (!trans.ok) {
2356
- log.warn(`${LOG_PREFIX} dropped illegal orch task transition`, { runId: event.runId, taskId: event.taskId, from: task.state, event: event.type })
2357
- return
2358
- }
2359
- }
2360
- task.updatedAt = event.timestamp
2361
- const slotOf = (t: OrchTaskRecord) => run.worktrees.find((w) => w.path === t.worktreePath)
2362
- switch (event.type) {
2363
- case "orch_task_claimed":
2364
- task.state = "claimed"
2365
- task.ownerWorkerId = event.workerId
2366
- task.worktreePath = event.worktreePath
2367
- task.branch = event.branch
2368
- task.baseSha = event.baseSha
2369
- task.attempts += 1
2370
- {
2371
- const slot = slotOf(task)
2372
- if (slot) slot.heldByTaskId = task.taskId
2373
- }
2374
- break
2375
- case "orch_phase_started":
2376
- task.state = "running"
2377
- task.phaseIndex = event.phaseIndex
2378
- task.verifying = false
2379
- break
2380
- case "orch_phase_completed":
2381
- task.lastPhaseOutput = event.output
2382
- break
2383
- case "orch_gate_opened":
2384
- task.state = "gated"
2385
- break
2386
- case "orch_gate_resolved":
2387
- if (event.decision === "approve") task.state = "running"
2388
- // reject: state stays gated; the engine appends orch_task_failed next
2389
- break
2390
- case "orch_verify_started":
2391
- task.verifying = true // observable stage; task stays "running"
2392
- break
2393
- case "orch_verify_completed":
2394
- task.verifying = false
2395
- break
2396
- case "orch_task_committed":
2397
- task.state = "committed"
2398
- task.ownerWorkerId = null
2399
- task.commitSha = event.commitSha
2400
- task.verifying = false
2401
- {
2402
- const slot = slotOf(task)
2403
- if (slot?.heldByTaskId === task.taskId) slot.heldByTaskId = null
2404
- }
2405
- break
2406
- case "orch_task_failed":
2407
- task.state = "failed"
2408
- task.ownerWorkerId = null
2409
- task.error = event.error
2410
- task.verifying = false
2411
- {
2412
- const slot = slotOf(task)
2413
- if (slot?.heldByTaskId === task.taskId) slot.heldByTaskId = null
2414
- }
2415
- break
2416
- case "orch_task_requeued":
2417
- // Slot hold deliberately KEPT (F13/F2): the task's uncommitted progress
2418
- // lives in its worktree — re-claim resumes the SAME slot.
2419
- task.state = "queued"
2420
- task.ownerWorkerId = null
2421
- task.verifying = false
2422
- break
2423
- }
2424
- }
474
+ scanAllToolRequests(): ToolRequest[] { return scanAllToolRequestsFromMap(this.state.toolRequestsById) }
2425
475
 
2426
- /**
2427
- * Apply synchronously, then enqueue the disk append — the sync apply is what
2428
- * makes an orchestration claim atomic within one event-loop turn (same
2429
- * pattern as appendSubagentEvent).
2430
- */
2431
- appendOrchestrationEvent(event: OrchestrationEvent): Promise<void> {
2432
- this.applyEvent(event)
2433
- this.enqueueDiskAppend(this.orchLogPath, `${JSON.stringify(event)}\n`)
2434
- this.notifyOrchRunsChanged()
2435
- return Promise.resolve()
2436
- }
476
+ async flush(): Promise<void> { await this.writeChain }
2437
477
 
2438
- /**
2439
- * Observe orchestration read-model changes. The callback fires after each
2440
- * live orch event is applied (not during boot replay — no subscribers yet).
2441
- * Mirrors the registry `.subscribe()` pattern; used by ws-router to push the
2442
- * `orch-runs` topic. Returns an unsubscribe fn.
2443
- */
2444
- subscribeOrchRuns(cb: () => void): () => void {
2445
- this.orchRunsSubscribers.add(cb)
2446
- return () => { this.orchRunsSubscribers.delete(cb) }
2447
- }
478
+ private readonly orchSubscriptionState = OrchSubscription.createOrchSubscriptionState()
2448
479
 
2449
- private notifyOrchRunsChanged(): void {
2450
- for (const cb of this.orchRunsSubscribers) {
2451
- try { cb() } catch (err) { log.warn(`${LOG_PREFIX} orch-runs subscriber threw`, { err }) }
2452
- }
2453
- }
480
+ appendOrchestrationEvent(event: OrchestrationEvent): Promise<void> { return OrchSubscription.appendOrchestrationEvent(this.buildOrchSubscriptionDeps(), event) }
2454
481
 
2455
- getOrchRun(runId: string): OrchRunSnapshot | null {
2456
- const run = this.state.orchRunsById.get(runId)
2457
- if (!run) return null
2458
- return this.toOrchRunSnapshot(run)
2459
- }
482
+ subscribeOrchRuns(cb: () => void): () => void { return OrchSubscription.subscribeOrchRuns(this.buildOrchSubscriptionDeps(), cb) }
2460
483
 
2461
- getOrchRuns(): OrchRunSnapshot[] {
2462
- return [...this.state.orchRunsById.values()].map((r) => this.toOrchRunSnapshot(r))
2463
- }
484
+ getOrchRun(runId: string): OrchRunSnapshot | null { return OrchSubscription.getOrchRun(this.state.orchRunsById, runId) }
2464
485
 
2465
- private toOrchRunSnapshot(run: OrchRunRecord): OrchRunSnapshot {
2466
- const tasks: OrchTaskSnapshot[] = run.taskOrder.flatMap((taskId) => {
2467
- const t = run.tasksById.get(taskId)
2468
- if (!t) return []
2469
- return [{
2470
- taskId: t.taskId,
2471
- title: t.title,
2472
- state: t.state,
2473
- ownerWorkerId: t.ownerWorkerId,
2474
- worktreePath: t.worktreePath,
2475
- branch: t.branch,
2476
- baseSha: t.baseSha,
2477
- phaseIndex: t.phaseIndex,
2478
- attempts: t.attempts,
2479
- error: t.error,
2480
- commitSha: t.commitSha,
2481
- verifying: t.verifying,
2482
- updatedAt: t.updatedAt,
2483
- }]
2484
- })
2485
- return {
2486
- runId: run.runId,
2487
- status: run.status,
2488
- config: run.config,
2489
- tasks,
2490
- worktrees: run.worktrees.map((w) => ({ ...w })),
2491
- createdAt: run.createdAt,
2492
- updatedAt: run.updatedAt,
2493
- }
2494
- }
486
+ getOrchRuns(): OrchRunSnapshot[] { return OrchSubscription.getOrchRuns(this.state.orchRunsById) }
2495
487
 
2496
- /** Tasks a restart must RE-QUEUE. `gated` is deliberately excluded — a gated task is re-armed in place (gate re-notified), never requeued. */
2497
488
  *nonTerminalOrchTasks(): Iterable<{ runId: string; taskId: string; state: "claimed" | "running" }> {
2498
- for (const run of this.state.orchRunsById.values()) {
2499
- if (run.status !== "running") continue
2500
- for (const task of run.tasksById.values()) {
2501
- if (task.state === "claimed" || task.state === "running") {
2502
- yield { runId: run.runId, taskId: task.taskId, state: task.state }
2503
- }
2504
- }
2505
- }
489
+ yield* OrchSubscription.nonTerminalOrchTasks(this.state.orchRunsById)
2506
490
  }
2507
491
 
2508
- /** Tasks paused at a hard gate — re-armed (not requeued) by recoverOnStartup. */
2509
492
  *gatedOrchTasks(): Iterable<{ runId: string; taskId: string; phaseIndex: number }> {
2510
- for (const run of this.state.orchRunsById.values()) {
2511
- if (run.status !== "running") continue
2512
- for (const task of run.tasksById.values()) {
2513
- if (task.state === "gated") {
2514
- yield { runId: run.runId, taskId: task.taskId, phaseIndex: task.phaseIndex }
2515
- }
2516
- }
2517
- }
493
+ yield* OrchSubscription.gatedOrchTasks(this.state.orchRunsById)
2518
494
  }
2519
495
 
2520
- /** Task spec lookup for the engine (records keep prompt/scope; snapshots do not). */
2521
496
  getOrchTaskSpec(runId: string, taskId: string): { prompt: string; scopePaths: string[] } | null {
2522
- const task = this.state.orchRunsById.get(runId)?.tasksById.get(taskId)
2523
- if (!task) return null
2524
- return { prompt: task.prompt, scopePaths: task.scopePaths }
497
+ return OrchSubscription.getOrchTaskSpec(this.state.orchRunsById, runId, taskId)
2525
498
  }
2526
499
 
2527
- /** Last completed phase's output {{PRIOR}} context when resuming a gated/recovered task. */
2528
- getOrchLastPhaseOutput(runId: string, taskId: string): string | null {
2529
- return this.state.orchRunsById.get(runId)?.tasksById.get(taskId)?.lastPhaseOutput ?? null
2530
- }
500
+ getOrchLastPhaseOutput(runId: string, taskId: string): string | null { return OrchSubscription.getOrchLastPhaseOutput(this.state.orchRunsById, runId, taskId) }
2531
501
 
2532
- /** Full ordered event timeline for one run — the rich drill-in source (F8). */
2533
- getOrchRunEvents(runId: string): OrchestrationEvent[] {
2534
- return [...(this.state.orchRunsById.get(runId)?.eventLog ?? [])]
2535
- }
502
+ getOrchRunEvents(runId: string): OrchestrationEvent[] { return OrchSubscription.getOrchRunEvents(this.state.orchRunsById, runId) }
2536
503
  }