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