@cuongtran001/kanna 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Standalone chat management functions for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the chat lifecycle and steering logic lives in
5
+ * its own testable module. The coordinator delegates to these functions by
6
+ * passing an object literal that satisfies `ChatManagementDeps`.
7
+ *
8
+ * Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
9
+ * calls, no Bun primitives). Every effectful operation is injected through
10
+ * the deps interface.
11
+ */
12
+
13
+ import type { AgentProvider, QueuedChatMessage } from "../shared/types"
14
+ import type { ClientCommand } from "../shared/protocol"
15
+ import type { ClaudeSessionState } from "./claude-session-state"
16
+ import type { GenerateChatTitleResult } from "./generate-title"
17
+ import { logClaudeSteer } from "./claude-steer-log"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Structural sub-interfaces — only the slices each function calls.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Subset of the activeTurns map used by chat management. */
24
+ interface ActiveTurnsMap {
25
+ has(chatId: string): boolean
26
+ get(chatId: string): { proactiveCompactInjection?: boolean } | undefined
27
+ }
28
+
29
+ /** Subset of the drainingStreams map used by chat management. */
30
+ interface DrainingStreamsMap {
31
+ get(chatId: string): { turn: { close(): void } } | undefined
32
+ has(chatId: string): boolean
33
+ delete(chatId: string): boolean
34
+ }
35
+
36
+ /** Subset of the claudeSessions map used by chat management. */
37
+ interface ClaudeSessionsMap {
38
+ get(chatId: string): ClaudeSessionState | undefined
39
+ }
40
+
41
+ /** Subset of the autoResumeByChat map used by chat management. */
42
+ interface AutoResumeMap {
43
+ delete(chatId: string): boolean
44
+ }
45
+
46
+ /** Subset of EventStore used by the chat management functions. */
47
+ interface ChatManagementStore {
48
+ getQueuedMessage(chatId: string, queuedMessageId: string): QueuedChatMessage | null
49
+ removeQueuedMessage(chatId: string, queuedMessageId: string): Promise<void>
50
+ requireChat(chatId: string): {
51
+ title: string
52
+ provider: AgentProvider | null
53
+ sessionTokensByProvider: Partial<Record<AgentProvider, string | null>>
54
+ pendingForkSessionToken?: { provider: AgentProvider; token: string } | null
55
+ }
56
+ forkChat(chatId: string): Promise<{ id: string }>
57
+ renameChat(chatId: string, title: string): Promise<void>
58
+ }
59
+
60
+ /** Minimal analytics interface used by chat management. */
61
+ interface AnalyticsSubset {
62
+ track(eventName: string, properties?: Record<string, unknown>): void
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Dependency bundle injected by AgentCoordinator
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export interface ChatManagementDeps {
70
+ /** Active turn map — read-only for management functions. */
71
+ activeTurns: ActiveTurnsMap
72
+ /** Draining streams map — management functions read and mutate it. */
73
+ drainingStreams: DrainingStreamsMap
74
+ /** Active Claude session map — used by closeChat to find the session. */
75
+ claudeSessions: ClaudeSessionsMap
76
+ /** Per-chat auto-resume flags — cleared on closeChat. */
77
+ autoResumeByChat: AutoResumeMap
78
+ /** Subset of EventStore needed by management functions. */
79
+ store: ChatManagementStore
80
+ /** Analytics reporter for tracking events. */
81
+ analytics: AnalyticsSubset
82
+ /** Cancel the active turn for a chat. */
83
+ cancel(chatId: string, options?: { hideInterrupted?: boolean; skipQueueDrain?: boolean }): Promise<void>
84
+ /** Close the Claude session for a chat, freeing its process slot. */
85
+ closeClaudeSession(chatId: string, session: ClaudeSessionState, opts?: { keepReservation?: boolean }): void
86
+ /** Notify listeners that the observable state for chatId has changed. */
87
+ emitStateChange(chatId: string): void
88
+ /** Generate a title for a chat message (AI-assisted). */
89
+ generateTitle(messageContent: string, cwd: string): Promise<GenerateChatTitleResult>
90
+ /** Report a non-fatal background error (e.g. title generation failure). Nullable for optional injection. */
91
+ reportBackgroundError: ((message: string) => void) | null
92
+ /** Dequeue a queued message and start processing it. */
93
+ dequeueAndStartQueuedMessage(
94
+ chatId: string,
95
+ queuedMessage: QueuedChatMessage,
96
+ options?: { steered?: boolean },
97
+ ): Promise<void>
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Extracted functions
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Stop a draining stream for `chatId` if one is active.
106
+ * Closes the stream's turn, removes the entry, and notifies state listeners.
107
+ */
108
+ export async function stopDraining(deps: ChatManagementDeps, chatId: string): Promise<void> {
109
+ const draining = deps.drainingStreams.get(chatId)
110
+ if (!draining) return
111
+ draining.turn.close()
112
+ deps.drainingStreams.delete(chatId)
113
+ deps.emitStateChange(chatId)
114
+ }
115
+
116
+ /**
117
+ * Fully close a chat: stop any draining stream, close the Claude session,
118
+ * clear the auto-resume flag, and notify state listeners.
119
+ */
120
+ export async function closeChat(deps: ChatManagementDeps, chatId: string): Promise<void> {
121
+ await stopDraining(deps, chatId)
122
+ const claudeSession = deps.claudeSessions.get(chatId)
123
+ if (claudeSession) {
124
+ deps.closeClaudeSession(chatId, claudeSession)
125
+ }
126
+ deps.autoResumeByChat.delete(chatId)
127
+ deps.emitStateChange(chatId)
128
+ }
129
+
130
+ /**
131
+ * Steer a chat to a previously queued message: cancel the current active turn
132
+ * (if any) and immediately start the queued message.
133
+ */
134
+ export async function steer(
135
+ deps: ChatManagementDeps,
136
+ command: Extract<ClientCommand, { type: "message.steer" }>,
137
+ ): Promise<void> {
138
+ const queuedMessage = deps.store.getQueuedMessage(command.chatId, command.queuedMessageId)
139
+ if (!queuedMessage) {
140
+ throw new Error("Queued message not found")
141
+ }
142
+
143
+ logClaudeSteer("steer_requested", {
144
+ chatId: command.chatId,
145
+ queuedMessageId: command.queuedMessageId,
146
+ activeTurn: deps.activeTurns.has(command.chatId),
147
+ queuedMessagePreview: queuedMessage.content.slice(0, 160),
148
+ })
149
+
150
+ if (deps.activeTurns.has(command.chatId)) {
151
+ await deps.cancel(command.chatId, { hideInterrupted: true, skipQueueDrain: true })
152
+ }
153
+
154
+ logClaudeSteer("steer_after_cancel", {
155
+ chatId: command.chatId,
156
+ stillActive: deps.activeTurns.has(command.chatId),
157
+ })
158
+
159
+ if (deps.activeTurns.has(command.chatId)) {
160
+ throw new Error("Chat is still running")
161
+ }
162
+
163
+ await deps.dequeueAndStartQueuedMessage(command.chatId, queuedMessage, { steered: true })
164
+ }
165
+
166
+ /**
167
+ * Remove a queued message, refusing if a proactive compact turn is in flight
168
+ * (dropping the message mid-compact would silently lose user intent).
169
+ */
170
+ export async function dequeue(
171
+ deps: ChatManagementDeps,
172
+ command: Extract<ClientCommand, { type: "message.dequeue" }>,
173
+ ): Promise<void> {
174
+ const queuedMessage = deps.store.getQueuedMessage(command.chatId, command.queuedMessageId)
175
+ if (!queuedMessage) {
176
+ throw new Error("Queued message not found")
177
+ }
178
+
179
+ // Refuse to drop the queued message while a Kanna-injected `/compact`
180
+ // turn is running. The compact was triggered specifically to make room
181
+ // for this queued message; auto-draining it after compact completes
182
+ // would silently lose user intent and waste the compact spend.
183
+ const active = deps.activeTurns.get(command.chatId)
184
+ if (active?.proactiveCompactInjection) {
185
+ throw new Error("Cannot remove queued message while compact is running")
186
+ }
187
+
188
+ await deps.store.removeQueuedMessage(command.chatId, command.queuedMessageId)
189
+ }
190
+
191
+ /**
192
+ * Fork `chatId` into a new child chat, inheriting the parent's session token.
193
+ * Throws if the chat is active, has no provider, or has no session to fork.
194
+ */
195
+ export async function forkChat(deps: ChatManagementDeps, chatId: string): Promise<{ chatId: string }> {
196
+ const chat = deps.store.requireChat(chatId)
197
+ if (deps.activeTurns.has(chatId) || deps.drainingStreams.has(chatId)) {
198
+ throw new Error("Chat must be idle before forking")
199
+ }
200
+ if (!chat.provider) {
201
+ throw new Error("Chat must have a provider before forking")
202
+ }
203
+ const currentProviderToken = chat.provider
204
+ ? (chat.sessionTokensByProvider[chat.provider] ?? null)
205
+ : null
206
+ const pendingForkForProvider =
207
+ chat.pendingForkSessionToken?.provider === chat.provider
208
+ ? chat.pendingForkSessionToken.token
209
+ : null
210
+ if (!currentProviderToken && !pendingForkForProvider) {
211
+ throw new Error("Chat has no session to fork")
212
+ }
213
+
214
+ const forked = await deps.store.forkChat(chatId)
215
+ deps.analytics.track("chat_created")
216
+ return { chatId: forked.id }
217
+ }
218
+
219
+ /**
220
+ * Generate a title for a chat message in the background, persisting it only
221
+ * if the chat's current title still matches `expectedCurrentTitle`.
222
+ * Errors are reported via `deps.reportBackgroundError` rather than thrown.
223
+ */
224
+ export async function generateTitleInBackground(
225
+ deps: ChatManagementDeps,
226
+ chatId: string,
227
+ messageContent: string,
228
+ cwd: string,
229
+ expectedCurrentTitle: string,
230
+ ): Promise<void> {
231
+ try {
232
+ const result = await deps.generateTitle(messageContent, cwd)
233
+ if (result.failureMessage) {
234
+ deps.reportBackgroundError?.(
235
+ `[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`,
236
+ )
237
+ }
238
+ if (!result.title || result.usedFallback) return
239
+
240
+ const chat = deps.store.requireChat(chatId)
241
+ if (chat.title !== expectedCurrentTitle) return
242
+
243
+ await deps.store.renameChat(chatId, result.title)
244
+ deps.emitStateChange(chatId)
245
+ } catch (error) {
246
+ const message = error instanceof Error ? error.message : String(error)
247
+ deps.reportBackgroundError?.(
248
+ `[title-generation] chat ${chatId} failed background title generation: ${message}`,
249
+ )
250
+ }
251
+ }
@@ -0,0 +1,273 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createClaudeHarnessStream } from "./claude-harness-stream"
3
+ import type { ClaudeRawSdkMessage } from "./claude-message-normalizer"
4
+ import type { HarnessEvent } from "./harness-types"
5
+ import type { ModelPrice } from "../shared/token-pricing"
6
+
7
+ function fakeQuery(messages: unknown[]): AsyncGenerator<ClaudeRawSdkMessage> {
8
+ return (async function* () {
9
+ for (const m of messages) yield m as ClaudeRawSdkMessage
10
+ })()
11
+ }
12
+
13
+ async function collect(
14
+ messages: unknown[],
15
+ configuredContextWindow?: number,
16
+ resolveTurnPrice?: () => ModelPrice | null,
17
+ ): Promise<HarnessEvent[]> {
18
+ const events: HarnessEvent[] = []
19
+ for await (const ev of createClaudeHarnessStream(
20
+ fakeQuery(messages),
21
+ configuredContextWindow,
22
+ resolveTurnPrice,
23
+ )) {
24
+ events.push(ev)
25
+ }
26
+ return events
27
+ }
28
+
29
+ describe("createClaudeHarnessStream", () => {
30
+ test("empty stream yields nothing", async () => {
31
+ const events = await collect([])
32
+ expect(events).toHaveLength(0)
33
+ })
34
+
35
+ test("session_token event emitted when sdkMessage.session_id is present", async () => {
36
+ const events = await collect([
37
+ {
38
+ type: "assistant",
39
+ session_id: "sess-abc",
40
+ message: { id: "m1", role: "assistant", content: [] },
41
+ },
42
+ ])
43
+ const tokens = events.filter((e) => e.type === "session_token")
44
+ expect(tokens).toHaveLength(1)
45
+ expect(tokens[0]).toEqual({ type: "session_token", sessionToken: "sess-abc" })
46
+ })
47
+
48
+ test("no session_token event when session_id is absent", async () => {
49
+ const events = await collect([
50
+ {
51
+ type: "result",
52
+ subtype: "success",
53
+ is_error: false,
54
+ duration_ms: 0,
55
+ uuid: "r1",
56
+ },
57
+ ])
58
+ const tokens = events.filter((e) => e.type === "session_token")
59
+ expect(tokens).toHaveLength(0)
60
+ })
61
+
62
+ test("rate_limit_event with resetsAt produces rate_limit HarnessEvent", async () => {
63
+ const resetAt = 1_748_800_000
64
+ const events = await collect([
65
+ {
66
+ type: "rate_limit_event",
67
+ session_id: "sess-rl",
68
+ rate_limit_info: { status: "rejected", resetsAt: resetAt },
69
+ },
70
+ ])
71
+ const rateLimits = events.filter((e) => e.type === "rate_limit")
72
+ expect(rateLimits).toHaveLength(1)
73
+ const ev = rateLimits[0]
74
+ expect(ev.type).toBe("rate_limit")
75
+ // SDK emits resetsAt as epoch seconds; limit-detector converts to ms
76
+ expect(ev.rateLimit?.resetAt).toBe(resetAt * 1000)
77
+ })
78
+
79
+ test("assistant message emits context_window_updated transcript entry", async () => {
80
+ const events = await collect([
81
+ {
82
+ type: "assistant",
83
+ session_id: "sess-1",
84
+ message: { id: "m1", role: "assistant", content: [{ type: "text", text: "hello" }] },
85
+ usage: { input_tokens: 100, output_tokens: 30 },
86
+ },
87
+ ])
88
+ const cwUpdated = events.filter(
89
+ (e) => e.type === "transcript" && e.entry?.kind === "context_window_updated",
90
+ )
91
+ expect(cwUpdated.length).toBeGreaterThanOrEqual(1)
92
+ })
93
+
94
+ test("result message produces enriched result entry with usage", async () => {
95
+ const events = await collect([
96
+ {
97
+ type: "assistant",
98
+ session_id: "sess-u",
99
+ message: { id: "m1", role: "assistant", content: [] },
100
+ usage: { input_tokens: 50, output_tokens: 20 },
101
+ },
102
+ {
103
+ type: "result",
104
+ subtype: "success",
105
+ session_id: "sess-u",
106
+ is_error: false,
107
+ duration_ms: 300,
108
+ usage: { input_tokens: 50, output_tokens: 20 },
109
+ uuid: "r1",
110
+ },
111
+ ])
112
+ const resultEntries = events.flatMap((e) =>
113
+ e.type === "transcript" && e.entry?.kind === "result" ? [e.entry] : [],
114
+ )
115
+ expect(resultEntries).toHaveLength(1)
116
+ expect(resultEntries[0].usage).toBeDefined()
117
+ })
118
+
119
+ test("api_error in turn scrubs result body (no duplicate text)", async () => {
120
+ const errorText = "You've hit your limit"
121
+ const events = await collect([
122
+ {
123
+ type: "assistant",
124
+ session_id: "sess-ae",
125
+ uuid: "msg-err",
126
+ isApiErrorMessage: true,
127
+ message: {
128
+ model: "<synthetic>",
129
+ content: [{ type: "text", text: errorText }],
130
+ },
131
+ },
132
+ {
133
+ type: "result",
134
+ subtype: "error",
135
+ session_id: "sess-ae",
136
+ is_error: true,
137
+ result: errorText,
138
+ duration_ms: 1000,
139
+ uuid: "r-ae",
140
+ },
141
+ ])
142
+ const apiErrorEntries = events.flatMap((e) =>
143
+ e.type === "transcript" && e.entry?.kind === "api_error" ? [e.entry] : [],
144
+ )
145
+ const resultEntries = events.flatMap((e) =>
146
+ e.type === "transcript" && e.entry?.kind === "result" ? [e.entry] : [],
147
+ )
148
+ expect(apiErrorEntries).toHaveLength(1)
149
+ expect(resultEntries).toHaveLength(1)
150
+ // Result body is scrubbed to "" to avoid duplicate display
151
+ expect(resultEntries[0].result).toBe("")
152
+ })
153
+
154
+ test("normalizeClaudeStreamMessage entries are yielded as transcript events", async () => {
155
+ const events = await collect([
156
+ {
157
+ type: "result",
158
+ subtype: "success",
159
+ session_id: "sess-norm",
160
+ is_error: false,
161
+ result: "done",
162
+ duration_ms: 50,
163
+ uuid: "r-norm",
164
+ },
165
+ ])
166
+ const transcriptEntries = events.filter((e) => e.type === "transcript")
167
+ expect(transcriptEntries.length).toBeGreaterThanOrEqual(1)
168
+ })
169
+
170
+ test("result with total_cost_usd attaches costUsd to result entry", async () => {
171
+ const events = await collect([
172
+ {
173
+ type: "result",
174
+ subtype: "success",
175
+ session_id: "sess-cost",
176
+ is_error: false,
177
+ duration_ms: 100,
178
+ total_cost_usd: 0.0042,
179
+ uuid: "r-cost",
180
+ },
181
+ ])
182
+ const resultEntries = events.flatMap((e) =>
183
+ e.type === "transcript" && e.entry?.kind === "result" ? [e.entry] : [],
184
+ )
185
+ expect(resultEntries).toHaveLength(1)
186
+ expect(resultEntries[0].costUsd).toBe(0.0042)
187
+ })
188
+
189
+ test("resolveTurnPrice callback used when total_cost_usd absent", async () => {
190
+ const price: ModelPrice = { inputPerMTok: 3, outputPerMTok: 15, cachedInputPerMTok: 0.3 }
191
+ const events = await collect(
192
+ [
193
+ {
194
+ type: "assistant",
195
+ session_id: "sess-price",
196
+ message: { id: "m1", role: "assistant", content: [] },
197
+ usage: { input_tokens: 1000, output_tokens: 100 },
198
+ },
199
+ {
200
+ type: "result",
201
+ subtype: "success",
202
+ session_id: "sess-price",
203
+ is_error: false,
204
+ duration_ms: 100,
205
+ usage: { input_tokens: 1000, output_tokens: 100 },
206
+ uuid: "r-price",
207
+ },
208
+ ],
209
+ undefined,
210
+ () => price,
211
+ )
212
+ const resultEntries = events.flatMap((e) =>
213
+ e.type === "transcript" && e.entry?.kind === "result" ? [e.entry] : [],
214
+ )
215
+ expect(resultEntries).toHaveLength(1)
216
+ expect(typeof resultEntries[0].costUsd).toBe("number")
217
+ expect((resultEntries[0].costUsd as number)).toBeGreaterThan(0)
218
+ })
219
+
220
+ test("duplicate assistant usage id is not double-counted", async () => {
221
+ const events = await collect([
222
+ {
223
+ type: "assistant",
224
+ session_id: "sess-dup",
225
+ message: { id: "dup-id", role: "assistant", content: [] },
226
+ usage: { input_tokens: 10, output_tokens: 5 },
227
+ },
228
+ {
229
+ type: "assistant",
230
+ session_id: "sess-dup",
231
+ message: { id: "dup-id", role: "assistant", content: [] },
232
+ usage: { input_tokens: 10, output_tokens: 5 },
233
+ },
234
+ ])
235
+ const cwUpdated = events.filter(
236
+ (e) => e.type === "transcript" && e.entry?.kind === "context_window_updated",
237
+ )
238
+ // Only one context_window_updated per unique usage id
239
+ expect(cwUpdated).toHaveLength(1)
240
+ })
241
+
242
+ test("configuredContextWindow floors the context window from SDK", async () => {
243
+ const events = await collect(
244
+ [
245
+ {
246
+ type: "assistant",
247
+ session_id: "sess-1m",
248
+ message: { id: "m1", role: "assistant", content: [] },
249
+ usage: { input_tokens: 100, output_tokens: 50 },
250
+ },
251
+ {
252
+ type: "result",
253
+ subtype: "success",
254
+ session_id: "sess-1m",
255
+ is_error: false,
256
+ duration_ms: 100,
257
+ usage: { input_tokens: 100, output_tokens: 50 },
258
+ modelUsage: { "claude-sonnet-4-6": { contextWindow: 200_000 } },
259
+ uuid: "r-1m",
260
+ },
261
+ ],
262
+ 1_000_000,
263
+ )
264
+ const cwUpdated = events.flatMap((e) =>
265
+ e.type === "transcript" && e.entry?.kind === "context_window_updated" ? [e.entry] : [],
266
+ )
267
+ // The configuredContextWindow (1M) should prevail over modelUsage (200K)
268
+ const lastSnapshot = cwUpdated[cwUpdated.length - 1]
269
+ expect(lastSnapshot).toBeDefined()
270
+ // maxTokens in the snapshot should reflect the 1M floor, not the 200K from modelUsage
271
+ expect(lastSnapshot.usage.maxTokens).toBeGreaterThanOrEqual(1_000_000)
272
+ })
273
+ })
@@ -0,0 +1,158 @@
1
+ import type { ContextWindowUsageSnapshot, ProviderUsage } from "../shared/types"
2
+ import { computeCostUsd } from "../shared/token-pricing"
3
+ import type { ModelPrice } from "../shared/token-pricing"
4
+ import type { HarnessEvent } from "./harness-types"
5
+ import {
6
+ timestamped,
7
+ type ClaudeRawSdkMessage,
8
+ getClaudeAssistantMessageUsageId,
9
+ normalizeClaudeStreamMessage,
10
+ } from "./claude-message-normalizer"
11
+ import {
12
+ normalizeClaudeUsageSnapshot,
13
+ resolveFinalTurnUsage,
14
+ maxClaudeContextWindowFromModelUsage,
15
+ } from "./claude-usage-math"
16
+ import { ClaudeLimitDetector } from "./auto-continue/limit-detector"
17
+
18
+ export async function* createClaudeHarnessStream(
19
+ q: AsyncIterable<ClaudeRawSdkMessage>,
20
+ configuredContextWindow?: number,
21
+ resolveTurnPrice?: () => ModelPrice | null,
22
+ ): AsyncGenerator<HarnessEvent> {
23
+ let seenAssistantUsageIds = new Set<string>()
24
+ let latestUsageSnapshot: ContextWindowUsageSnapshot | null = null
25
+ let lastKnownContextWindow: number | undefined = configuredContextWindow
26
+ const detector = new ClaudeLimitDetector()
27
+ // SDK rate-limit / api-error turns emit BOTH a synthetic assistant
28
+ // `isApiErrorMessage` (→ `api_error` entry, red card with text) AND a
29
+ // `type:"result"` whose `result` field repeats the same text (→ second
30
+ // red card + "Failed after Xs"). Track per-turn api_error emission so
31
+ // we can scrub the duplicate body off the trailing result entry; the
32
+ // duration footer still renders, the message renders once.
33
+ let apiErrorEmittedInTurn = false
34
+
35
+ // Per-turn billed token usage and cost to attach to the result entry.
36
+ // Set when the `type:"result"` SDK message is processed; cleared after
37
+ // the result entry is yielded so they don't leak to a subsequent turn.
38
+ let pendingResultUsage: ProviderUsage | undefined
39
+ let pendingResultCost: number | undefined
40
+
41
+ for await (const sdkMessage of q) {
42
+ const sessionToken = typeof sdkMessage.session_id === "string" ? sdkMessage.session_id : null
43
+ if (sessionToken) {
44
+ yield { type: "session_token", sessionToken }
45
+ }
46
+
47
+ if (sdkMessage?.type === "rate_limit_event") {
48
+ const detection = detector.detectFromSdkRateLimitInfo("", sdkMessage.rate_limit_info)
49
+ if (detection) {
50
+ yield { type: "rate_limit", rateLimit: { resetAt: detection.resetAt, tz: detection.tz } }
51
+ }
52
+ }
53
+
54
+ if (sdkMessage?.type === "assistant") {
55
+ const usageId = getClaudeAssistantMessageUsageId(sdkMessage)
56
+ const usageSnapshot = normalizeClaudeUsageSnapshot(sdkMessage.usage, lastKnownContextWindow)
57
+ if (usageId && usageSnapshot && !seenAssistantUsageIds.has(usageId)) {
58
+ seenAssistantUsageIds.add(usageId)
59
+ latestUsageSnapshot = usageSnapshot
60
+ yield {
61
+ type: "transcript",
62
+ entry: timestamped({
63
+ kind: "context_window_updated",
64
+ usage: usageSnapshot,
65
+ }),
66
+ }
67
+ }
68
+ }
69
+
70
+ if (sdkMessage?.type === "result") {
71
+ const resultContextWindow = maxClaudeContextWindowFromModelUsage(sdkMessage.modelUsage)
72
+ // Never let SDK lower the configured window — see comment on
73
+ // parseConfiguredContextWindowFromModelId for the 1M beta footgun.
74
+ if (resultContextWindow !== undefined) {
75
+ lastKnownContextWindow = Math.max(lastKnownContextWindow ?? 0, resultContextWindow)
76
+ }
77
+
78
+ const accumulatedUsage = normalizeClaudeUsageSnapshot(
79
+ sdkMessage.usage,
80
+ lastKnownContextWindow,
81
+ )
82
+ const finalUsage = resolveFinalTurnUsage(
83
+ latestUsageSnapshot,
84
+ accumulatedUsage,
85
+ lastKnownContextWindow,
86
+ )
87
+
88
+ const providerCostUsd =
89
+ typeof sdkMessage.total_cost_usd === "number"
90
+ ? sdkMessage.total_cost_usd
91
+ : undefined
92
+
93
+ let costUsd = providerCostUsd
94
+ if (costUsd === undefined && resolveTurnPrice && finalUsage) {
95
+ const price = resolveTurnPrice()
96
+ if (price) {
97
+ costUsd = computeCostUsd(
98
+ {
99
+ inputTokens: finalUsage.inputTokens,
100
+ cachedInputTokens: finalUsage.cachedInputTokens,
101
+ outputTokens: finalUsage.outputTokens,
102
+ },
103
+ price,
104
+ )
105
+ }
106
+ }
107
+
108
+ // Stash billed token figures for the result entry (populated below
109
+ // in the entry loop). Prefer `accumulatedUsage` (the per-turn
110
+ // cumulative that the SDK computes) for tokens; fall back to
111
+ // `finalUsage` when accumulated is null.
112
+ const billed = accumulatedUsage ?? finalUsage
113
+ pendingResultUsage = billed
114
+ ? {
115
+ ...(billed.inputTokens !== undefined ? { inputTokens: billed.inputTokens } : {}),
116
+ ...(billed.outputTokens !== undefined ? { outputTokens: billed.outputTokens } : {}),
117
+ ...(billed.cachedInputTokens !== undefined ? { cachedInputTokens: billed.cachedInputTokens } : {}),
118
+ }
119
+ : undefined
120
+ pendingResultCost = costUsd
121
+
122
+ if (finalUsage) {
123
+ const usageWithCost = costUsd !== undefined ? { ...finalUsage, costUsd } : finalUsage
124
+ yield {
125
+ type: "transcript",
126
+ entry: timestamped({
127
+ kind: "context_window_updated",
128
+ usage: usageWithCost,
129
+ }),
130
+ }
131
+ }
132
+
133
+ seenAssistantUsageIds = new Set<string>()
134
+ latestUsageSnapshot = null
135
+ }
136
+
137
+ for (const entry of normalizeClaudeStreamMessage(sdkMessage)) {
138
+ if (entry.kind === "api_error") {
139
+ apiErrorEmittedInTurn = true
140
+ } else if (entry.kind === "result") {
141
+ const scrubbed = entry.isError && apiErrorEmittedInTurn
142
+ ? { ...entry, result: "" }
143
+ : entry
144
+ apiErrorEmittedInTurn = false
145
+ const enriched = {
146
+ ...scrubbed,
147
+ ...(pendingResultUsage !== undefined ? { usage: pendingResultUsage } : {}),
148
+ ...(pendingResultCost !== undefined ? { costUsd: pendingResultCost } : {}),
149
+ }
150
+ pendingResultUsage = undefined
151
+ pendingResultCost = undefined
152
+ yield { type: "transcript", entry: enriched }
153
+ continue
154
+ }
155
+ yield { type: "transcript", entry }
156
+ }
157
+ }
158
+ }