@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
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Pure event-builder functions for EventStore write operations.
3
+ *
4
+ * Each function validates its inputs (throwing on error), then constructs and
5
+ * returns the event object that the EventStore should persist. Returning `null`
6
+ * signals a no-op (state is already the desired value, so no event is needed).
7
+ *
8
+ * Extracted from event-store.ts to reduce file size. All functions are pure —
9
+ * no IO, no side effects (side-effect seal: IO lives in *.adapter.ts files).
10
+ */
11
+
12
+ import path from "node:path"
13
+ import type { AgentProvider, QueuedChatMessage, SlashCommand, StackBinding } from "../shared/types"
14
+ import { STORE_VERSION } from "../shared/types"
15
+ import type { ChatPermissionPolicyOverride, ToolRequest, ToolRequestDecision, ToolRequestStatus } from "../shared/permission-policy"
16
+ import type {
17
+ ChatEvent,
18
+ ChatRecord,
19
+ ProjectEvent,
20
+ ProjectRecord,
21
+ QueuedMessageEvent,
22
+ StackEvent,
23
+ StackRecord,
24
+ StoreState,
25
+ ToolRequestEvent,
26
+ TurnEvent,
27
+ TurnRunConfig,
28
+ } from "./events"
29
+ import { resolveLocalPath } from "./paths"
30
+ import { slashCommandsEqual } from "./event-store-helpers"
31
+
32
+ // ─── Internal helper ───────────────────────────────────────────────────────
33
+
34
+ function requireChat(chatsById: Map<string, ChatRecord>, chatId: string): ChatRecord {
35
+ const chat = chatsById.get(chatId)
36
+ if (!chat || chat.deletedAt) throw new Error("Chat not found")
37
+ return chat
38
+ }
39
+
40
+ // ─── Project builders ─────────────────────────────────────────────────────
41
+
42
+ export type OpenProjectResult =
43
+ | { kind: "existing"; project: ProjectRecord }
44
+ | { kind: "new"; event: Extract<ProjectEvent, { type: "project_opened" }> }
45
+
46
+ /** Resolves an existing project or builds the `project_opened` event for a new one. */
47
+ export function buildOpenProjectResult(
48
+ state: Pick<StoreState, "projectsById" | "projectIdsByPath">,
49
+ localPath: string,
50
+ title?: string,
51
+ ): OpenProjectResult {
52
+ const normalized = resolveLocalPath(localPath)
53
+ const existingId = state.projectIdsByPath.get(normalized)
54
+ if (existingId) {
55
+ const existing = state.projectsById.get(existingId)
56
+ if (existing && !existing.deletedAt) return { kind: "existing", project: existing }
57
+ }
58
+ const hiddenProject = [...state.projectsById.values()]
59
+ .find((p) => p.localPath === normalized && p.deletedAt)
60
+ const projectId = hiddenProject?.id ?? crypto.randomUUID()
61
+ const event: Extract<ProjectEvent, { type: "project_opened" }> = {
62
+ v: STORE_VERSION,
63
+ type: "project_opened",
64
+ timestamp: Date.now(),
65
+ projectId,
66
+ localPath: normalized,
67
+ title: title?.trim() || path.basename(normalized) || normalized,
68
+ }
69
+ return { kind: "new", event }
70
+ }
71
+
72
+ /** Builds the `project_removed` event. Throws if the project is not found. */
73
+ export function buildRemoveProjectEvent(
74
+ projectsById: Map<string, { id: string; deletedAt?: number }>,
75
+ projectId: string,
76
+ ): ProjectEvent {
77
+ const project = projectsById.get(projectId)
78
+ if (!project || project.deletedAt) throw new Error("Project not found")
79
+ return { v: STORE_VERSION, type: "project_removed", timestamp: Date.now(), projectId }
80
+ }
81
+
82
+ /** Builds the `project_star_set` event. Throws if the project is not found. */
83
+ export function buildSetProjectStarEvent(
84
+ projectsById: Map<string, { id: string; deletedAt?: number }>,
85
+ projectId: string,
86
+ starred: boolean,
87
+ ): ProjectEvent {
88
+ const project = projectsById.get(projectId)
89
+ if (!project || project.deletedAt) throw new Error("Project not found")
90
+ const now = Date.now()
91
+ return {
92
+ v: STORE_VERSION,
93
+ type: "project_star_set",
94
+ timestamp: now,
95
+ projectId,
96
+ starredAt: starred ? now : null,
97
+ }
98
+ }
99
+
100
+ // ─── Stack builders ───────────────────────────────────────────────────────
101
+
102
+ /** Builds the `stack_added` event. Throws on invalid inputs. */
103
+ export function buildCreateStackEvent(
104
+ state: Pick<StoreState, "projectsById" | "stacksById">,
105
+ title: string,
106
+ projectIds: string[],
107
+ ): StackEvent & { stackId: string } {
108
+ const trimmed = title.trim()
109
+ if (trimmed === "") throw new Error("Stack title cannot be empty")
110
+ if (projectIds.length < 2) throw new Error("Stack requires at least 2 projects")
111
+ if (new Set(projectIds).size !== projectIds.length) throw new Error("Stack projectIds contain duplicates")
112
+ for (const id of projectIds) {
113
+ const project = state.projectsById.get(id)
114
+ if (!project || project.deletedAt) throw new Error(`Project not found: ${id}`)
115
+ }
116
+ const stackId = crypto.randomUUID()
117
+ return {
118
+ v: STORE_VERSION,
119
+ type: "stack_added",
120
+ timestamp: Date.now(),
121
+ stackId,
122
+ title: trimmed,
123
+ projectIds: [...projectIds],
124
+ }
125
+ }
126
+
127
+ /** Builds the `stack_renamed` event, or `null` if the title is unchanged. Throws if not found. */
128
+ export function buildRenameStackEvent(
129
+ stacksById: Map<string, StackRecord>,
130
+ stackId: string,
131
+ title: string,
132
+ ): StackEvent | null {
133
+ const stack = stacksById.get(stackId)
134
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
135
+ const trimmed = title.trim()
136
+ if (trimmed === "") throw new Error("Stack title cannot be empty")
137
+ if (trimmed === stack.title) return null
138
+ return { v: STORE_VERSION, type: "stack_renamed", timestamp: Date.now(), stackId, title: trimmed }
139
+ }
140
+
141
+ /** Builds the `stack_removed` event, or `null` if already deleted. Throws if not found. */
142
+ export function buildRemoveStackEvent(
143
+ stacksById: Map<string, StackRecord>,
144
+ stackId: string,
145
+ ): StackEvent | null {
146
+ const stack = stacksById.get(stackId)
147
+ if (!stack) throw new Error("Stack not found")
148
+ if (stack.deletedAt) return null
149
+ return { v: STORE_VERSION, type: "stack_removed", timestamp: Date.now(), stackId }
150
+ }
151
+
152
+ /** Builds the `stack_project_added` event, or `null` if already a member. Throws if not found. */
153
+ export function buildAddProjectToStackEvent(
154
+ state: Pick<StoreState, "projectsById" | "stacksById">,
155
+ stackId: string,
156
+ projectId: string,
157
+ ): StackEvent | null {
158
+ const stack = state.stacksById.get(stackId)
159
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
160
+ const project = state.projectsById.get(projectId)
161
+ if (!project || project.deletedAt) throw new Error("Project not found")
162
+ if (stack.projectIds.includes(projectId)) return null
163
+ return { v: STORE_VERSION, type: "stack_project_added", timestamp: Date.now(), stackId, projectId }
164
+ }
165
+
166
+ /** Builds the `stack_project_removed` event, or `null` if not a member. Throws on constraint violations. */
167
+ export function buildRemoveProjectFromStackEvent(
168
+ stacksById: Map<string, StackRecord>,
169
+ stackId: string,
170
+ projectId: string,
171
+ ): StackEvent | null {
172
+ const stack = stacksById.get(stackId)
173
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
174
+ if (!stack.projectIds.includes(projectId)) return null
175
+ if (stack.projectIds.length <= 2) {
176
+ throw new Error("Stack must keep at least 2 projects. Delete the stack instead.")
177
+ }
178
+ return { v: STORE_VERSION, type: "stack_project_removed", timestamp: Date.now(), stackId, projectId }
179
+ }
180
+
181
+ // ─── Sidebar order ────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Computes the new de-duplicated + validated sidebar order.
185
+ * Returns `null` if the order is unchanged (caller can skip the write).
186
+ */
187
+ export function computeNewSidebarOrder(
188
+ projectsById: Map<string, { deletedAt?: number }>,
189
+ currentOrder: string[],
190
+ requestedIds: string[],
191
+ ): string[] | null {
192
+ const valid = requestedIds.filter((id) => {
193
+ const p = projectsById.get(id)
194
+ return Boolean(p && !p.deletedAt)
195
+ })
196
+ const unique = [...new Set(valid)]
197
+ if (
198
+ unique.length === currentOrder.length
199
+ && unique.every((id, i) => currentOrder[i] === id)
200
+ ) {
201
+ return null
202
+ }
203
+ return unique
204
+ }
205
+
206
+ // ─── Chat lifecycle builders ───────────────────────────────────────────────
207
+
208
+ /** Builds the `chat_created` event after full validation. Throws on invalid inputs. */
209
+ export function buildCreateChatEvent(
210
+ state: Pick<StoreState, "projectsById" | "stacksById">,
211
+ projectId: string,
212
+ options?: { stackId?: string; stackBindings?: StackBinding[] },
213
+ ): ChatEvent & { chatId: string } {
214
+ const project = state.projectsById.get(projectId)
215
+ if (!project || project.deletedAt) throw new Error("Project not found")
216
+
217
+ if (options?.stackId !== undefined || options?.stackBindings !== undefined) {
218
+ if (options.stackId === undefined || options.stackBindings === undefined) {
219
+ throw new Error("stackId and stackBindings must be provided together")
220
+ }
221
+ const stack = state.stacksById.get(options.stackId)
222
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
223
+ if (options.stackBindings.length === 0) throw new Error("stackBindings cannot be empty")
224
+ const primaries = options.stackBindings.filter((b) => b.role === "primary")
225
+ if (primaries.length !== 1) throw new Error("Exactly one primary binding required")
226
+ const seenProjects = new Set<string>()
227
+ for (const binding of options.stackBindings) {
228
+ if (seenProjects.has(binding.projectId)) throw new Error("Duplicate projectId in stackBindings")
229
+ seenProjects.add(binding.projectId)
230
+ if (!stack.projectIds.includes(binding.projectId)) {
231
+ throw new Error(`Binding projectId not a member of stack: ${binding.projectId}`)
232
+ }
233
+ const peer = state.projectsById.get(binding.projectId)
234
+ if (!peer || peer.deletedAt) throw new Error(`Project not found: ${binding.projectId}`)
235
+ if (typeof binding.worktreePath !== "string" || binding.worktreePath.trim() === "") {
236
+ throw new Error("worktreePath must be a non-empty string")
237
+ }
238
+ }
239
+ if (primaries[0].projectId !== projectId) {
240
+ throw new Error("Primary binding projectId must match createChat projectId")
241
+ }
242
+ }
243
+
244
+ const chatId = crypto.randomUUID()
245
+ return {
246
+ v: STORE_VERSION,
247
+ type: "chat_created",
248
+ timestamp: Date.now(),
249
+ chatId,
250
+ projectId,
251
+ title: "New Chat",
252
+ ...(options?.stackId !== undefined ? { stackId: options.stackId } : {}),
253
+ ...(options?.stackBindings !== undefined
254
+ ? { stackBindings: options.stackBindings.map((b) => ({ ...b })) }
255
+ : {}),
256
+ }
257
+ }
258
+
259
+ /** Builds the `chat_renamed` event, or `null` if the title is unchanged / empty. */
260
+ export function buildRenameChatEvent(
261
+ chatsById: Map<string, ChatRecord>,
262
+ chatId: string,
263
+ title: string,
264
+ ): ChatEvent | null {
265
+ const trimmed = title.trim()
266
+ if (!trimmed) return null
267
+ const chat = requireChat(chatsById, chatId)
268
+ if (chat.title === trimmed) return null
269
+ return { v: STORE_VERSION, type: "chat_renamed", timestamp: Date.now(), chatId, title: trimmed }
270
+ }
271
+
272
+ /** Builds the `chat_archived` event. Throws if the chat is not found. */
273
+ export function buildArchiveChatEvent(chatsById: Map<string, ChatRecord>, chatId: string): ChatEvent {
274
+ requireChat(chatsById, chatId)
275
+ return { v: STORE_VERSION, type: "chat_archived", timestamp: Date.now(), chatId }
276
+ }
277
+
278
+ /** Builds the `chat_unarchived` event. Throws if the chat is not found. */
279
+ export function buildUnarchiveChatEvent(chatsById: Map<string, ChatRecord>, chatId: string): ChatEvent {
280
+ requireChat(chatsById, chatId)
281
+ return { v: STORE_VERSION, type: "chat_unarchived", timestamp: Date.now(), chatId }
282
+ }
283
+
284
+ // ─── Chat state-setter builders ────────────────────────────────────────────
285
+
286
+ /** Builds the `chat_provider_set` event, or `null` if the provider is unchanged. */
287
+ export function buildChatProviderEvent(
288
+ chatsById: Map<string, ChatRecord>,
289
+ chatId: string,
290
+ provider: AgentProvider,
291
+ ): ChatEvent | null {
292
+ const chat = requireChat(chatsById, chatId)
293
+ if (chat.provider === provider) return null
294
+ return { v: STORE_VERSION, type: "chat_provider_set", timestamp: Date.now(), chatId, provider }
295
+ }
296
+
297
+ /** Builds the `chat_plan_mode_set` event, or `null` if unchanged. */
298
+ export function buildPlanModeEvent(
299
+ chatsById: Map<string, ChatRecord>,
300
+ chatId: string,
301
+ planMode: boolean,
302
+ ): ChatEvent | null {
303
+ const chat = requireChat(chatsById, chatId)
304
+ if (chat.planMode === planMode) return null
305
+ return { v: STORE_VERSION, type: "chat_plan_mode_set", timestamp: Date.now(), chatId, planMode }
306
+ }
307
+
308
+ /** Builds the `chat_compact_failures_set` event, or `null` if unchanged. */
309
+ export function buildCompactFailuresEvent(
310
+ chatsById: Map<string, ChatRecord>,
311
+ chatId: string,
312
+ compactFailureCount: number,
313
+ ): ChatEvent | null {
314
+ const chat = requireChat(chatsById, chatId)
315
+ if ((chat.compactFailureCount ?? 0) === compactFailureCount) return null
316
+ return {
317
+ v: STORE_VERSION, type: "chat_compact_failures_set", timestamp: Date.now(),
318
+ chatId, compactFailureCount,
319
+ }
320
+ }
321
+
322
+ /** Builds the `chat_read_state_set` event, or `null` if unchanged. */
323
+ export function buildChatReadStateEvent(
324
+ chatsById: Map<string, ChatRecord>,
325
+ chatId: string,
326
+ unread: boolean,
327
+ ): ChatEvent | null {
328
+ const chat = requireChat(chatsById, chatId)
329
+ if (chat.unread === unread) return null
330
+ return { v: STORE_VERSION, type: "chat_read_state_set", timestamp: Date.now(), chatId, unread }
331
+ }
332
+
333
+ /** Builds the `chat_policy_override_set` event. Throws if the chat is not found. */
334
+ export function buildChatPolicyOverrideEvent(
335
+ chatsById: Map<string, ChatRecord>,
336
+ chatId: string,
337
+ policyOverride: ChatPermissionPolicyOverride | null,
338
+ ): ChatEvent {
339
+ requireChat(chatsById, chatId)
340
+ return {
341
+ v: STORE_VERSION, type: "chat_policy_override_set", timestamp: Date.now(),
342
+ chatId, policyOverride,
343
+ }
344
+ }
345
+
346
+ /** Builds the `chat_source_hash_set` event, or `null` if unchanged. */
347
+ export function buildChatSourceHashEvent(
348
+ chatsById: Map<string, ChatRecord>,
349
+ chatId: string,
350
+ sourceHash: string | null,
351
+ ): ChatEvent | null {
352
+ const chat = requireChat(chatsById, chatId)
353
+ if (chat.sourceHash === sourceHash) return null
354
+ return { v: STORE_VERSION, type: "chat_source_hash_set", timestamp: Date.now(), chatId, sourceHash }
355
+ }
356
+
357
+ // ─── Queued-message builders ───────────────────────────────────────────────
358
+
359
+ /** Builds the `queued_message_enqueued` event and the resolved QueuedChatMessage. */
360
+ export function buildEnqueueMessageResult(
361
+ chatsById: Map<string, ChatRecord>,
362
+ chatId: string,
363
+ message: Omit<QueuedChatMessage, "id" | "createdAt"> & Partial<Pick<QueuedChatMessage, "id" | "createdAt">>,
364
+ ): { event: QueuedMessageEvent; queuedMessage: QueuedChatMessage } {
365
+ requireChat(chatsById, chatId)
366
+ const queuedMessage: QueuedChatMessage = {
367
+ id: message.id ?? crypto.randomUUID(),
368
+ content: message.content,
369
+ attachments: [...(message.attachments ?? [])],
370
+ createdAt: message.createdAt ?? Date.now(),
371
+ provider: message.provider,
372
+ model: message.model,
373
+ modelOptions: message.modelOptions,
374
+ planMode: message.planMode,
375
+ autoContinue: message.autoContinue,
376
+ }
377
+ const event: QueuedMessageEvent = {
378
+ v: STORE_VERSION,
379
+ type: "queued_message_enqueued",
380
+ timestamp: queuedMessage.createdAt,
381
+ chatId,
382
+ message: queuedMessage,
383
+ }
384
+ return { event, queuedMessage }
385
+ }
386
+
387
+ /** Builds the `queued_message_removed` event. Throws if not found. */
388
+ export function buildRemoveQueuedMessageEvent(
389
+ chatsById: Map<string, ChatRecord>,
390
+ queuedMessagesByChatId: Map<string, QueuedChatMessage[]>,
391
+ chatId: string,
392
+ queuedMessageId: string,
393
+ ): QueuedMessageEvent {
394
+ requireChat(chatsById, chatId)
395
+ const existing = queuedMessagesByChatId.get(chatId) ?? []
396
+ if (!existing.some((e) => e.id === queuedMessageId)) throw new Error("Queued message not found")
397
+ return {
398
+ v: STORE_VERSION, type: "queued_message_removed",
399
+ timestamp: Date.now(), chatId, queuedMessageId,
400
+ }
401
+ }
402
+
403
+ // ─── Turn / session builders ───────────────────────────────────────────────
404
+
405
+ /** Builds the `turn_started` event. Throws if the chat is not found. */
406
+ export function buildTurnStartedEvent(
407
+ chatsById: Map<string, ChatRecord>,
408
+ chatId: string,
409
+ runConfig?: TurnRunConfig,
410
+ ): TurnEvent {
411
+ requireChat(chatsById, chatId)
412
+ return {
413
+ v: STORE_VERSION, type: "turn_started", timestamp: Date.now(),
414
+ chatId, ...(runConfig ? { runConfig } : {}),
415
+ }
416
+ }
417
+
418
+ /** Builds the `turn_finished` event. Throws if the chat is not found. */
419
+ export function buildTurnFinishedEvent(chatsById: Map<string, ChatRecord>, chatId: string): TurnEvent {
420
+ requireChat(chatsById, chatId)
421
+ return { v: STORE_VERSION, type: "turn_finished", timestamp: Date.now(), chatId }
422
+ }
423
+
424
+ /** Builds the `turn_failed` event. Throws if the chat is not found. */
425
+ export function buildTurnFailedEvent(
426
+ chatsById: Map<string, ChatRecord>,
427
+ chatId: string,
428
+ error: string,
429
+ ): TurnEvent {
430
+ requireChat(chatsById, chatId)
431
+ return { v: STORE_VERSION, type: "turn_failed", timestamp: Date.now(), chatId, error }
432
+ }
433
+
434
+ /** Builds the `turn_cancelled` event. Throws if the chat is not found. */
435
+ export function buildTurnCancelledEvent(chatsById: Map<string, ChatRecord>, chatId: string): TurnEvent {
436
+ requireChat(chatsById, chatId)
437
+ return { v: STORE_VERSION, type: "turn_cancelled", timestamp: Date.now(), chatId }
438
+ }
439
+
440
+ /** Builds the `session_token_set` event, or `null` if unchanged. */
441
+ export function buildSessionTokenEvent(
442
+ chatsById: Map<string, ChatRecord>,
443
+ chatId: string,
444
+ provider: AgentProvider,
445
+ sessionToken: string | null,
446
+ ): TurnEvent | null {
447
+ const chat = requireChat(chatsById, chatId)
448
+ if ((chat.sessionTokensByProvider[provider] ?? null) === sessionToken) return null
449
+ return {
450
+ v: STORE_VERSION, type: "session_token_set", timestamp: Date.now(),
451
+ chatId, sessionToken, provider,
452
+ }
453
+ }
454
+
455
+ /** Builds the `session_commands_loaded` event, or `null` if commands are unchanged. */
456
+ export function buildSessionCommandsEvent(
457
+ chatsById: Map<string, ChatRecord>,
458
+ chatId: string,
459
+ commands: SlashCommand[],
460
+ ): TurnEvent | null {
461
+ const chat = requireChat(chatsById, chatId)
462
+ const normalized = commands.map((c) => ({
463
+ name: c.name,
464
+ description: c.description,
465
+ argumentHint: c.argumentHint,
466
+ }))
467
+ if (chat.slashCommands && slashCommandsEqual(chat.slashCommands, normalized)) return null
468
+ return {
469
+ v: STORE_VERSION, type: "session_commands_loaded", timestamp: Date.now(),
470
+ chatId, commands: normalized,
471
+ }
472
+ }
473
+
474
+ /** Builds the `pending_fork_session_token_set` event, or `null` if unchanged. */
475
+ export function buildPendingForkSessionTokenEvent(
476
+ chatsById: Map<string, ChatRecord>,
477
+ chatId: string,
478
+ value: { provider: AgentProvider; token: string } | null,
479
+ ): TurnEvent | null {
480
+ const chat = requireChat(chatsById, chatId)
481
+ const current = chat.pendingForkSessionToken ?? null
482
+ const same =
483
+ (current == null && value == null)
484
+ || (current != null && value != null
485
+ && current.provider === value.provider
486
+ && current.token === value.token)
487
+ if (same) return null
488
+ return {
489
+ v: STORE_VERSION, type: "pending_fork_session_token_set", timestamp: Date.now(),
490
+ chatId,
491
+ pendingForkSessionToken: value?.token ?? null,
492
+ provider: value?.provider,
493
+ }
494
+ }
495
+
496
+ // ─── Tool-request builders ────────────────────────────────────────────────
497
+
498
+ /** Builds the `tool_request_put` event. */
499
+ export function buildPutToolRequestEvent(req: ToolRequest): ToolRequestEvent {
500
+ return { v: 3, type: "tool_request_put", timestamp: Date.now(), request: req }
501
+ }
502
+
503
+ /** Builds the `tool_request_resolved` event. Throws if the request id is unknown. */
504
+ export function buildResolveToolRequestEvent(
505
+ toolRequestsById: Map<string, ToolRequest>,
506
+ id: string,
507
+ args: {
508
+ status: ToolRequestStatus
509
+ decision?: ToolRequestDecision
510
+ resolvedAt: number
511
+ mismatchReason?: string
512
+ },
513
+ ): ToolRequestEvent {
514
+ if (!toolRequestsById.has(id)) throw new Error(`resolveToolRequest: unknown id ${id}`)
515
+ return {
516
+ v: 3,
517
+ type: "tool_request_resolved",
518
+ timestamp: Date.now(),
519
+ id,
520
+ status: args.status,
521
+ decision: args.decision,
522
+ resolvedAt: args.resolvedAt,
523
+ mismatchReason: args.mismatchReason,
524
+ }
525
+ }
526
+
527
+ // ─── Re-export ProjectRecord for callers of OpenProjectResult ─────────────
528
+
529
+ export type { ProjectRecord } from "./events"
@@ -853,6 +853,41 @@ describe("EventStore subagent runs", () => {
853
853
  expect(runs[runId].finalText).toBe("hello world")
854
854
  })
855
855
 
856
+ test("subagent runs are tracked for chats restored from snapshot.json", async () => {
857
+ const dataDir = await createTempDataDir()
858
+ const store = new EventStore(dataDir)
859
+ await store.initialize()
860
+ const project = await store.openProject("/tmp/p-sa-snap")
861
+ const chat = await store.createChat(project.id)
862
+
863
+ // Fold chat_created into snapshot.json and truncate the event logs —
864
+ // the same state a server reboot leaves behind for pre-existing chats.
865
+ await store.snapshotAndTruncateLogs()
866
+
867
+ const rebooted = new EventStore(dataDir)
868
+ await rebooted.initialize()
869
+
870
+ const runId = "r-after-reboot"
871
+ await rebooted.appendSubagentEvent({
872
+ v: 3,
873
+ type: "subagent_run_started",
874
+ timestamp: chat.createdAt + 1,
875
+ chatId: chat.id,
876
+ runId,
877
+ subagentId: "s1",
878
+ subagentName: "alpha",
879
+ provider: "claude",
880
+ model: "claude-opus-4-7",
881
+ parentUserMessageId: "u1",
882
+ parentRunId: null,
883
+ depth: 0,
884
+ })
885
+
886
+ const runs = rebooted.getSubagentRuns(chat.id)
887
+ expect(runs[runId]).toBeDefined()
888
+ expect(runs[runId].status).toBe("running")
889
+ })
890
+
856
891
  test("subagent_message_delta accumulates into finalText; run_completed sets canonical", async () => {
857
892
  const dataDir = await createTempDataDir()
858
893
  const store = new EventStore(dataDir)