@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,420 @@
1
+ /**
2
+ * Standalone send/queue handler cluster for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the message-send pipeline and queue management
5
+ * logic lives in its own testable module. The coordinator delegates to these
6
+ * functions by passing an object literal that satisfies `SendCommandDeps`.
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, ChatAttachment, CustomModelEntry, QueuedChatMessage, TranscriptEntry } from "../shared/types"
14
+ import { resolveClaudeApiModelId } from "../shared/types"
15
+ import type { ClientCommand } from "../shared/protocol"
16
+ import {
17
+ logSendToStartingProfile,
18
+ type SendMessageOptions,
19
+ type SendToStartingProfile,
20
+ } from "./claude-steer-log"
21
+ import type { StartTurnForChatArgs } from "./claude-turn-starter"
22
+ import {
23
+ getLatestContextWindowUsage,
24
+ MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
25
+ shouldProactivelyCompact,
26
+ } from "./proactive-compact"
27
+ import {
28
+ codexServiceTierFromModelOptions,
29
+ getServerProviderCatalog,
30
+ normalizeClaudeModelOptions,
31
+ normalizeCodexModelOptions,
32
+ normalizeServerModel,
33
+ } from "./provider-catalog"
34
+ import { buildSteeredMessageContent } from "./claude-prompt-helpers"
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Structural sub-interfaces — only the slices this module calls.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Subset of EventStore used by the send command handler. */
41
+ interface SendCommandStore {
42
+ createChat(projectId: string): Promise<{ id: string }>
43
+ requireChat(chatId: string): { provider: AgentProvider | null }
44
+ getChat(chatId: string): { compactFailureCount?: number } | null
45
+ enqueueMessage(
46
+ chatId: string,
47
+ message: Omit<QueuedChatMessage, "id" | "createdAt"> & Partial<Pick<QueuedChatMessage, "id" | "createdAt">>,
48
+ ): Promise<QueuedChatMessage>
49
+ removeQueuedMessage(chatId: string, queuedMessageId: string): Promise<void>
50
+ getQueuedMessages?: (chatId: string) => readonly QueuedChatMessage[]
51
+ getMessages(chatId: string): readonly TranscriptEntry[]
52
+ }
53
+
54
+ /** Subset of the activeTurns map used by the send command handler. */
55
+ interface ActiveTurnsMap {
56
+ has(chatId: string): boolean
57
+ get(chatId: string): { proactiveCompactInjection?: boolean } | undefined
58
+ }
59
+
60
+ /** Subset of the claudeSessions map used by the send command handler. */
61
+ interface ClaudeSessionsMap {
62
+ get(chatId: string): { backgroundTaskIds: Set<string>; backgroundTaskDeadlineAt: number } | undefined
63
+ }
64
+
65
+ /** Subset of the autoResumeByChat map used by the send command handler. */
66
+ interface AutoResumeByChatMap {
67
+ set(chatId: string, value: boolean): void
68
+ }
69
+
70
+ /** Minimal analytics interface needed by the send command handler. */
71
+ interface SendCommandAnalytics {
72
+ track(event: string): void
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Dependency bundle injected by AgentCoordinator
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface SendCommandDeps {
80
+ /** The event store — for creating chats, queuing messages, and reading state. */
81
+ store: SendCommandStore
82
+
83
+ /** The active-turns map. Read-only from the handler's perspective (has/get). */
84
+ activeTurns: ActiveTurnsMap
85
+
86
+ /** The claude-sessions map. Used to clear background-task state on user send. */
87
+ claudeSessions: ClaudeSessionsMap
88
+
89
+ /** Per-chat auto-resume preference map. */
90
+ autoResumeByChat: AutoResumeByChatMap
91
+
92
+ /** Analytics reporter. */
93
+ analytics: SendCommandAnalytics
94
+
95
+ /** Returns the current app settings snapshot (for customModels). */
96
+ getAppSettingsSnapshot(): { customModels?: readonly CustomModelEntry[] }
97
+
98
+ /** Disarm the armed loop (user takeover). */
99
+ stopLoop(chatId: string, reason: "goal_met" | "user_send" | "chat_deleted"): Promise<void>
100
+
101
+ /** Emit a state-change event for a chat. */
102
+ emitStateChange(chatId: string): void
103
+
104
+ /** Start a new provider turn for the given chat. */
105
+ startTurnForChat(args: StartTurnForChatArgs): Promise<void>
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Pure helpers (no deps required)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Resolve the provider to use for a new message, falling back through the
114
+ * command option → chat's current provider → "claude".
115
+ */
116
+ export function resolveProvider(
117
+ options: SendMessageOptions,
118
+ currentProvider: AgentProvider | null,
119
+ ): AgentProvider {
120
+ return options.provider ?? currentProvider ?? "claude"
121
+ }
122
+
123
+ /**
124
+ * Resolve the model/effort/planMode settings for a new provider turn.
125
+ * Falls through provider-specific normalization logic.
126
+ */
127
+ export function getProviderSettings(
128
+ provider: AgentProvider,
129
+ options: SendMessageOptions,
130
+ customModels: readonly CustomModelEntry[],
131
+ ) {
132
+ const catalog = getServerProviderCatalog(provider)
133
+
134
+ if (provider === "claude") {
135
+ const model = normalizeServerModel(provider, options.model, customModels)
136
+ const modelOptions = normalizeClaudeModelOptions(model, options.modelOptions, options.effort, customModels)
137
+ return {
138
+ model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
139
+ effort: modelOptions.reasoningEffort,
140
+ serviceTier: undefined,
141
+ planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
142
+ }
143
+ }
144
+
145
+ if (provider === "openrouter") {
146
+ // OpenRouter's model list is fetched dynamically (settings.listOpenRouterModels),
147
+ // so the static server catalog is empty and normalizeServerModel would collapse
148
+ // every selection to the default. Trust the client-selected id — OpenRouter
149
+ // rejects invalid ids at the API — falling back to the default only when blank.
150
+ return {
151
+ model: options.model?.trim() || catalog.defaultModel,
152
+ effort: undefined,
153
+ serviceTier: undefined,
154
+ planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
155
+ }
156
+ }
157
+
158
+ const modelOptions = normalizeCodexModelOptions(options.modelOptions, options.effort)
159
+ return {
160
+ model: normalizeServerModel(provider, options.model, customModels),
161
+ effort: modelOptions.reasoningEffort,
162
+ serviceTier: codexServiceTierFromModelOptions(modelOptions),
163
+ planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Exported standalone functions
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Check whether a proactive `/compact` turn should be injected before the
173
+ * user's real message. Returns false for slash commands and when the circuit
174
+ * breaker has tripped (too many consecutive compact failures).
175
+ */
176
+ export function shouldInjectProactiveCompact(
177
+ deps: SendCommandDeps,
178
+ chatId: string,
179
+ content: string,
180
+ ): boolean {
181
+ // Never recurse — if the user (or Kanna itself) is already sending a
182
+ // slash command, run it as-is. Compacting before `/clear` or another
183
+ // `/compact` would be wasted work.
184
+ if (content.trimStart().startsWith("/")) return false
185
+ const failures = deps.store.getChat(chatId)?.compactFailureCount ?? 0
186
+ if (failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) return false
187
+ const usage = getLatestContextWindowUsage(deps.store.getMessages(chatId))
188
+ return shouldProactivelyCompact(usage)
189
+ }
190
+
191
+ /**
192
+ * Append a message to the chat's queue and emit a state-change event.
193
+ * Returns the newly created queued message.
194
+ */
195
+ export async function enqueueMessage(
196
+ deps: SendCommandDeps,
197
+ chatId: string,
198
+ content: string,
199
+ attachments: ChatAttachment[],
200
+ options?: SendMessageOptions,
201
+ ): Promise<QueuedChatMessage> {
202
+ const queued = await deps.store.enqueueMessage(chatId, {
203
+ content,
204
+ attachments,
205
+ provider: options?.provider,
206
+ model: options?.model,
207
+ modelOptions: options?.modelOptions,
208
+ planMode: options?.planMode,
209
+ autoContinue: options?.autoContinue,
210
+ })
211
+ deps.emitStateChange(chatId)
212
+ return queued
213
+ }
214
+
215
+ /**
216
+ * Dequeue a specific queued message and start a turn for it.
217
+ * If `options.steered` is true, the content is wrapped as a steered message.
218
+ */
219
+ export async function dequeueAndStartQueuedMessage(
220
+ deps: SendCommandDeps,
221
+ chatId: string,
222
+ queuedMessage: QueuedChatMessage,
223
+ options?: { steered?: boolean },
224
+ ): Promise<void> {
225
+ await deps.store.removeQueuedMessage(chatId, queuedMessage.id)
226
+ const chat = deps.store.requireChat(chatId)
227
+
228
+ // Mentions no longer short-circuit the main turn (Anthropic-style
229
+ // Task-tool pattern). The main agent always runs; mention metadata is
230
+ // still attached to the user_prompt entry by `startTurnForChat` →
231
+ // `appendUserPrompt`.
232
+ const provider = resolveProvider(queuedMessage, chat.provider)
233
+ const customModels = deps.getAppSettingsSnapshot().customModels ?? []
234
+ const settings = getProviderSettings(provider, queuedMessage, customModels)
235
+
236
+ // Auto-continue rate-limit recovery sends the literal "continue" as a
237
+ // resume signal. Appending it as a user_prompt entry adds noise to the
238
+ // transcript (shows as an "auto-sent" bubble right before a COMPACTED
239
+ // divider, confusing the user). Suppress the entry for that fallback
240
+ // case; agent-driven wakes with a meaningful custom prompt still appear.
241
+ const isRateLimitFallback = queuedMessage.autoContinue !== undefined
242
+ && queuedMessage.content === "continue"
243
+
244
+ await deps.startTurnForChat({
245
+ chatId,
246
+ provider,
247
+ content: options?.steered ? buildSteeredMessageContent(queuedMessage.content) : queuedMessage.content,
248
+ attachments: queuedMessage.attachments,
249
+ model: settings.model,
250
+ effort: settings.effort,
251
+ serviceTier: settings.serviceTier,
252
+ planMode: settings.planMode,
253
+ appendUserPrompt: !isRateLimitFallback,
254
+ steered: options?.steered,
255
+ autoContinue: queuedMessage.autoContinue,
256
+ })
257
+ }
258
+
259
+ /**
260
+ * If no turn is active and a queued message exists, dequeue and start it.
261
+ * Returns `true` if a queued message was started, `false` otherwise.
262
+ */
263
+ export async function maybeStartNextQueuedMessage(
264
+ deps: SendCommandDeps,
265
+ chatId: string,
266
+ ): Promise<boolean> {
267
+ if (deps.activeTurns.has(chatId)) return false
268
+ const nextQueuedMessage = typeof deps.store.getQueuedMessages === "function"
269
+ ? deps.store.getQueuedMessages(chatId)[0]
270
+ : undefined
271
+ if (!nextQueuedMessage) return false
272
+ await dequeueAndStartQueuedMessage(deps, chatId, nextQueuedMessage)
273
+ return true
274
+ }
275
+
276
+ /**
277
+ * Handle a `chat.send` command:
278
+ * 1. Clears background-task keep-alive guard on the existing session.
279
+ * 2. Disarms any armed loop (user takeover).
280
+ * 3. Creates the chat if it doesn't exist yet.
281
+ * 4. If a turn is active, enqueues the message and returns `queued: true`.
282
+ * 5. Optionally injects a proactive `/compact` turn ahead of the real message.
283
+ * 6. Otherwise starts the turn immediately.
284
+ */
285
+ export async function sendCommand(
286
+ deps: SendCommandDeps,
287
+ command: Extract<ClientCommand, { type: "chat.send" }>,
288
+ ): Promise<{ chatId: string; queuedMessageId?: string; queued?: true }> {
289
+ const profile: SendToStartingProfile | null = command.clientTraceId
290
+ ? { traceId: command.clientTraceId, startedAt: performance.now() }
291
+ : null
292
+ let chatId = command.chatId
293
+
294
+ // A real user chat.send means the agent is active again — release any
295
+ // background-task keep-alive guard so the session reaps normally afterward.
296
+ // Auto-continue / agent wakes bypass `send` and intentionally do NOT clear it.
297
+ const existingClaudeSession = chatId ? deps.claudeSessions.get(chatId) : undefined
298
+ if (existingClaudeSession) {
299
+ existingClaudeSession.backgroundTaskIds.clear()
300
+ existingClaudeSession.backgroundTaskDeadlineAt = 0
301
+ }
302
+
303
+ // A real user send is a takeover: disarm any armed loop so tools are
304
+ // restored and the generic wake path resumes. Auto-continue / background
305
+ // wakes bypass `send`, so they do NOT disarm.
306
+ // Awaited so a failed event-log write surfaces instead of silently
307
+ // leaving the loop armed (and tools blocked) after the takeover.
308
+ if (chatId) await deps.stopLoop(chatId, "user_send")
309
+
310
+ logSendToStartingProfile(profile, "chat_send.received", {
311
+ existingChatId: command.chatId ?? null,
312
+ projectId: command.projectId ?? null,
313
+ })
314
+
315
+ if (!chatId) {
316
+ if (!command.projectId) {
317
+ throw new Error("Missing projectId for new chat")
318
+ }
319
+ const created = await deps.store.createChat(command.projectId)
320
+ chatId = created.id
321
+ deps.analytics.track("chat_created")
322
+ logSendToStartingProfile(profile, "chat_send.chat_created", {
323
+ chatId,
324
+ projectId: command.projectId,
325
+ })
326
+ }
327
+
328
+ if (typeof command.autoResumeOnRateLimit === "boolean" && chatId) {
329
+ deps.autoResumeByChat.set(chatId, command.autoResumeOnRateLimit)
330
+ }
331
+
332
+ if (deps.activeTurns.has(chatId)) {
333
+ deps.analytics.track("message_sent")
334
+ const queuedMessage = await enqueueMessage(deps, chatId, command.content, command.attachments ?? [], {
335
+ provider: command.provider,
336
+ model: command.model,
337
+ modelOptions: command.modelOptions,
338
+ effort: command.effort,
339
+ planMode: command.planMode,
340
+ })
341
+ return { chatId, queuedMessageId: queuedMessage.id, queued: true as const }
342
+ }
343
+
344
+ // Mentions no longer short-circuit the main turn. The main agent always
345
+ // runs and decides whether to delegate via `mcp__kanna__delegate_subagent`
346
+ // (Anthropic-style Task-tool pattern). `parseMentions` still runs inside
347
+ // `startTurnForChat` → `appendUserPrompt` so the user_prompt entry
348
+ // continues to carry `subagentMentions` metadata for UI badges + analytics.
349
+ const chat = deps.store.requireChat(chatId)
350
+ const provider = resolveProvider(command, chat.provider)
351
+ const customModels = deps.getAppSettingsSnapshot().customModels ?? []
352
+ const settings = getProviderSettings(provider, command, customModels)
353
+ deps.analytics.track("message_sent")
354
+
355
+ // Proactive compact: if the latest usage snapshot crossed claude-code's
356
+ // auto-compact threshold, inject a synthetic `/compact` turn ahead of the
357
+ // user's real message. The user's prompt sits in the queue and runs after
358
+ // `/compact` produces its summary, so the next turn ships with a bounded
359
+ // history instead of looping on "Prompt is too long".
360
+ if (
361
+ provider === "claude" // openrouter intentionally excluded: /compact is claude-CLI-specific
362
+ && shouldInjectProactiveCompact(deps, chatId, command.content)
363
+ ) {
364
+ const queuedMessage = await enqueueMessage(deps, chatId, command.content, command.attachments ?? [], {
365
+ provider: command.provider,
366
+ model: command.model,
367
+ modelOptions: command.modelOptions,
368
+ effort: command.effort,
369
+ planMode: command.planMode,
370
+ })
371
+ await deps.startTurnForChat({
372
+ chatId,
373
+ provider,
374
+ content: "/compact",
375
+ attachments: [],
376
+ model: settings.model,
377
+ effort: settings.effort,
378
+ serviceTier: settings.serviceTier,
379
+ planMode: settings.planMode,
380
+ // /compact is a slash command, not the user's actual message — don't
381
+ // persist a user_prompt transcript entry for it.
382
+ appendUserPrompt: false,
383
+ profile,
384
+ })
385
+ // Tag the active turn so the result handler can update the circuit
386
+ // breaker (reset on success / increment on failure).
387
+ const compactActive = deps.activeTurns.get(chatId)
388
+ if (compactActive) compactActive.proactiveCompactInjection = true
389
+
390
+ logSendToStartingProfile(profile, "chat_send.proactive_compact_injected", {
391
+ chatId,
392
+ provider,
393
+ model: settings.model,
394
+ queuedUserMessageId: queuedMessage.id,
395
+ })
396
+
397
+ return { chatId, queuedMessageId: queuedMessage.id, queued: true as const }
398
+ }
399
+
400
+ await deps.startTurnForChat({
401
+ chatId,
402
+ provider,
403
+ content: command.content,
404
+ attachments: command.attachments ?? [],
405
+ model: settings.model,
406
+ effort: settings.effort,
407
+ serviceTier: settings.serviceTier,
408
+ planMode: settings.planMode,
409
+ appendUserPrompt: true,
410
+ profile,
411
+ })
412
+
413
+ logSendToStartingProfile(profile, "chat_send.ready_for_ack", {
414
+ chatId,
415
+ provider,
416
+ model: settings.model,
417
+ })
418
+
419
+ return { chatId }
420
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test"
2
+ import {
3
+ resolveClaudeDriverPreference,
4
+ getEnabledCustomMcpServers,
5
+ buildOAuthBearers,
6
+ resolveChatPolicy,
7
+ killPtyInstance,
8
+ type ClaudeSessionConfigHelpersDeps,
9
+ } from "./claude-session-config-helpers"
10
+ import { POLICY_DEFAULT } from "../shared/permission-policy"
11
+ import type { McpServerConfig, McpOAuthState } from "../shared/types"
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers / stubs
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function makeServer(overrides: Partial<McpServerConfig & { enabled: boolean }> = {}): McpServerConfig {
18
+ return {
19
+ id: "srv-1",
20
+ name: "test-server",
21
+ enabled: true,
22
+ createdAt: "2024-01-01T00:00:00.000Z",
23
+ updatedAt: "2024-01-01T00:00:00.000Z",
24
+ lastTest: { status: "none" as const },
25
+ transport: "http",
26
+ url: "https://example.com/mcp",
27
+ headers: {},
28
+ ...overrides,
29
+ } as McpServerConfig
30
+ }
31
+
32
+ function makeDeps(overrides: Partial<ClaudeSessionConfigHelpersDeps> = {}): ClaudeSessionConfigHelpersDeps {
33
+ return {
34
+ getAppSettingsSnapshot: () => ({}),
35
+ chatPolicy: POLICY_DEFAULT,
36
+ store: { state: null },
37
+ ptyInstanceRegistry: null,
38
+ ensureFreshToken: async () => "test-token",
39
+ persistOAuthState: null,
40
+ killProcessTree: async (_pid) => {},
41
+ ...overrides,
42
+ }
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // resolveClaudeDriverPreference
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("resolveClaudeDriverPreference", () => {
50
+ const originalEnv = process.env.KANNA_CLAUDE_DRIVER
51
+
52
+ beforeEach(() => {
53
+ delete process.env.KANNA_CLAUDE_DRIVER
54
+ })
55
+
56
+ test("returns 'pty' when settings preference is 'pty'", () => {
57
+ const deps = makeDeps({
58
+ getAppSettingsSnapshot: () => ({ claudeDriver: { preference: "pty" } }),
59
+ })
60
+ expect(resolveClaudeDriverPreference(deps)).toBe("pty")
61
+ })
62
+
63
+ test("returns 'sdk' when settings preference is 'sdk'", () => {
64
+ const deps = makeDeps({
65
+ getAppSettingsSnapshot: () => ({ claudeDriver: { preference: "sdk" } }),
66
+ })
67
+ expect(resolveClaudeDriverPreference(deps)).toBe("sdk")
68
+ })
69
+
70
+ test("falls through to env var when settings have no preference", () => {
71
+ process.env.KANNA_CLAUDE_DRIVER = "pty"
72
+ const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
73
+ expect(resolveClaudeDriverPreference(deps)).toBe("pty")
74
+ // restore
75
+ process.env.KANNA_CLAUDE_DRIVER = originalEnv ?? ""
76
+ if (!originalEnv) delete process.env.KANNA_CLAUDE_DRIVER
77
+ })
78
+
79
+ test("defaults to 'sdk' when no settings and no env var", () => {
80
+ const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
81
+ expect(resolveClaudeDriverPreference(deps)).toBe("sdk")
82
+ })
83
+ })
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // getEnabledCustomMcpServers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("getEnabledCustomMcpServers", () => {
90
+ test("returns only enabled servers", () => {
91
+ const enabled = makeServer({ id: "s1", enabled: true })
92
+ const disabled = makeServer({ id: "s2", enabled: false })
93
+ const deps = makeDeps({
94
+ getAppSettingsSnapshot: () => ({ customMcpServers: [enabled, disabled] }),
95
+ })
96
+ const result = getEnabledCustomMcpServers(deps)
97
+ expect(result).toHaveLength(1)
98
+ expect(result[0].id).toBe("s1")
99
+ })
100
+
101
+ test("returns empty array when customMcpServers is not an array", () => {
102
+ const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
103
+ expect(getEnabledCustomMcpServers(deps)).toEqual([])
104
+ })
105
+
106
+ test("returns empty array when all servers are disabled", () => {
107
+ const deps = makeDeps({
108
+ getAppSettingsSnapshot: () => ({
109
+ customMcpServers: [makeServer({ enabled: false })],
110
+ }),
111
+ })
112
+ expect(getEnabledCustomMcpServers(deps)).toEqual([])
113
+ })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // buildOAuthBearers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("buildOAuthBearers", () => {
121
+ test("skips stdio servers", async () => {
122
+ const ensureFreshToken = mock(async () => "token")
123
+ const deps = makeDeps({ ensureFreshToken })
124
+ const stdioServer = makeServer({ transport: "stdio", command: "node", args: [], env: {} })
125
+ const result = await buildOAuthBearers(deps, [stdioServer as McpServerConfig])
126
+ expect(result.size).toBe(0)
127
+ expect(ensureFreshToken).not.toHaveBeenCalled()
128
+ })
129
+
130
+ test("skips unauthenticated servers", async () => {
131
+ const ensureFreshToken = mock(async () => "token")
132
+ const unauthOauth: McpOAuthState = { enabled: true, status: "unauthenticated" }
133
+ const server = makeServer({ oauth: unauthOauth })
134
+ const deps = makeDeps({ ensureFreshToken })
135
+ const result = await buildOAuthBearers(deps, [server])
136
+ expect(result.size).toBe(0)
137
+ expect(ensureFreshToken).not.toHaveBeenCalled()
138
+ })
139
+
140
+ test("fetches token for authenticated network servers and stores by id", async () => {
141
+ const ensureFreshToken = mock(async () => "bearer-xyz")
142
+ const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
143
+ const server = makeServer({ id: "srv-auth", oauth: authOauth })
144
+ const deps = makeDeps({ ensureFreshToken })
145
+ const result = await buildOAuthBearers(deps, [server])
146
+ expect(result.size).toBe(1)
147
+ expect(result.get("srv-auth")).toBe("bearer-xyz")
148
+ expect(ensureFreshToken).toHaveBeenCalledTimes(1)
149
+ })
150
+
151
+ test("calls persistOAuthState when token is refreshed", async () => {
152
+ const persisted: Array<{ id: string; oauth: McpOAuthState }> = []
153
+ const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
154
+ const server = makeServer({ id: "srv-p", oauth: authOauth })
155
+ const deps = makeDeps({
156
+ ensureFreshToken: async (_s, opts) => {
157
+ opts.persist(authOauth)
158
+ return "new-token"
159
+ },
160
+ persistOAuthState: (id, oauth) => { persisted.push({ id, oauth }) },
161
+ })
162
+ await buildOAuthBearers(deps, [server])
163
+ expect(persisted).toHaveLength(1)
164
+ expect(persisted[0].id).toBe("srv-p")
165
+ })
166
+
167
+ test("continues processing other servers when one throws", async () => {
168
+ const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
169
+ const bad = makeServer({ id: "bad", oauth: authOauth })
170
+ const good = makeServer({ id: "good", oauth: authOauth, name: "good-server" })
171
+ const ensureFreshToken = mock(async (s: McpServerConfig) => {
172
+ if (s.id === "bad") throw new Error("refresh failed")
173
+ return "good-token"
174
+ })
175
+ const deps = makeDeps({ ensureFreshToken })
176
+ const result = await buildOAuthBearers(deps, [bad, good])
177
+ expect(result.has("bad")).toBe(false)
178
+ expect(result.get("good")).toBe("good-token")
179
+ })
180
+ })
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // resolveChatPolicy
184
+ // ---------------------------------------------------------------------------
185
+
186
+ describe("resolveChatPolicy", () => {
187
+ test("returns base chatPolicy when store has no state", () => {
188
+ const deps = makeDeps({ store: { state: null } })
189
+ const result = resolveChatPolicy(deps, "chat-1")
190
+ expect(result).toBe(POLICY_DEFAULT)
191
+ })
192
+
193
+ test("returns base chatPolicy when chat has no policyOverride", () => {
194
+ const deps = makeDeps({
195
+ store: {
196
+ state: {
197
+ chatsById: new Map([["chat-1", { policyOverride: null }]]),
198
+ },
199
+ },
200
+ })
201
+ const result = resolveChatPolicy(deps, "chat-1")
202
+ expect(result).toEqual(POLICY_DEFAULT)
203
+ })
204
+
205
+ test("applies policyOverride when present", () => {
206
+ const override = { defaultAction: "auto-allow" as const }
207
+ const deps = makeDeps({
208
+ store: {
209
+ state: {
210
+ chatsById: new Map([["chat-1", { policyOverride: override }]]),
211
+ },
212
+ },
213
+ })
214
+ const result = resolveChatPolicy(deps, "chat-1")
215
+ expect(result.defaultAction).toBe("auto-allow")
216
+ })
217
+
218
+ test("falls back to base policy when chat id not in store", () => {
219
+ const deps = makeDeps({
220
+ store: {
221
+ state: { chatsById: new Map() },
222
+ },
223
+ })
224
+ const result = resolveChatPolicy(deps, "nonexistent")
225
+ expect(result).toEqual(POLICY_DEFAULT)
226
+ })
227
+ })
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // killPtyInstance
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe("killPtyInstance", () => {
234
+ test("throws when no PTY instance found for chat", async () => {
235
+ const deps = makeDeps({
236
+ ptyInstanceRegistry: {
237
+ snapshot: () => [],
238
+ markExitedIfCurrent: () => {},
239
+ },
240
+ })
241
+ await expect(killPtyInstance(deps, "chat-1")).rejects.toThrow("No live PTY instance for chat")
242
+ })
243
+
244
+ test("throws when PTY instance has no pid", async () => {
245
+ const deps = makeDeps({
246
+ ptyInstanceRegistry: {
247
+ snapshot: () => [{ chatId: "chat-1", pid: null }],
248
+ markExitedIfCurrent: () => {},
249
+ },
250
+ })
251
+ await expect(killPtyInstance(deps, "chat-1")).rejects.toThrow("No live PTY instance for chat")
252
+ })
253
+
254
+ test("calls killProcessTree with the instance pid", async () => {
255
+ const killed: number[] = []
256
+ const deps = makeDeps({
257
+ ptyInstanceRegistry: {
258
+ snapshot: () => [{ chatId: "chat-1", pid: 42 }],
259
+ markExitedIfCurrent: () => {},
260
+ },
261
+ killProcessTree: async (pid) => { killed.push(pid) },
262
+ })
263
+ await killPtyInstance(deps, "chat-1")
264
+ expect(killed).toEqual([42])
265
+ })
266
+
267
+ test("calls markExitedIfCurrent after killProcessTree", async () => {
268
+ const marked: Array<{ chatId: string; pid: number }> = []
269
+ const deps = makeDeps({
270
+ ptyInstanceRegistry: {
271
+ snapshot: () => [{ chatId: "chat-1", pid: 99 }],
272
+ markExitedIfCurrent: (chatId, pid) => { marked.push({ chatId, pid }) },
273
+ },
274
+ })
275
+ await killPtyInstance(deps, "chat-1")
276
+ expect(marked).toHaveLength(1)
277
+ expect(marked[0]).toMatchObject({ chatId: "chat-1", pid: 99 })
278
+ })
279
+ })