@cuongtran001/kanna 1.3.0 → 1.4.0

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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.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.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Standalone Claude session spawner — the logic extracted from the private
3
+ * `startClaudeTurn` method of AgentCoordinator.
4
+ *
5
+ * Responsibilities:
6
+ * - Decide whether an existing session can be reused or must be evicted and
7
+ * replaced (localPath / effort / forkSession / loop-armed flip).
8
+ * - Pick an OAuth pool token, look up OpenRouter pricing, build the system
9
+ * prompt append and delegation context, then spawn the ClaudeSessionHandle
10
+ * via either the PTY or SDK driver.
11
+ * - Register the new ClaudeSessionState in the claudeSessions map, fire
12
+ * enforceClaudeSessionBudget, start the session event loop, and load slash
13
+ * commands in the background.
14
+ * - When reusing an existing session: update lastUsedAt, call setModel /
15
+ * setPermissionMode if the options changed.
16
+ * - Return a HarnessTurn built from the session handle.
17
+ *
18
+ * Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
19
+ * calls, no Bun primitives). Every effectful operation is injected through
20
+ * SpawnClaudeTurnDeps so the module remains testable without real drivers.
21
+ */
22
+
23
+ import type { AnyValue } from "../shared/errors"
24
+ import type {
25
+ AgentProvider,
26
+ ClaudeDriverPreference,
27
+ LlmProviderSnapshot,
28
+ McpServerConfig,
29
+ OpenRouterModel,
30
+ ResolvedStackBinding,
31
+ Subagent,
32
+ } from "../shared/types"
33
+ import { buildKannaSystemPromptAppend } from "../shared/kanna-system-prompt"
34
+ import { resolveModelPrice, stripModelVariantSuffix } from "../shared/token-pricing"
35
+ import type { ModelPrice } from "../shared/token-pricing"
36
+ import { maskOauthKey } from "../shared/mask-oauth-key"
37
+ import { log } from "../shared/log"
38
+ import { OAuthPoolUnavailableError } from "./oauth-errors"
39
+ import type { ClaudeSessionHandle, HarnessTurn, HarnessToolRequest } from "./harness-types"
40
+ import type { ActiveTurn, ClaudeSessionState, SlashCommand } from "./claude-session-state"
41
+ import type { KannaMcpDelegationContext, SetupLoopHandlerResult } from "./kanna-mcp"
42
+ import type { LoopSetupInput } from "./loop-template"
43
+ import type { LoopState } from "./auto-continue/read-model"
44
+ import type { ChatPermissionPolicy } from "../shared/permission-policy"
45
+ import type { OrchRunDetail, OrchRunInput } from "../shared/orchestration-types"
46
+ import type { StartClaudeSessionPtyArgs } from "./claude-pty/driver"
47
+ import type { SubagentOrchestrator } from "./subagent-orchestrator"
48
+ import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
49
+ import type { ToolCallbackService } from "./tool-callback"
50
+ import type { ClaudePtyRegistry } from "./claude-pty/pid-registry.adapter"
51
+ import type { PtyInstanceRegistry } from "./claude-pty/pty-instance-registry"
52
+ import type { WorkflowRegistry } from "./workflow-registry"
53
+ import type { SubagentTranscriptRegistry } from "./subagent-transcript-registry"
54
+ // Type-only import from the SDK session start module — no IO is pulled in.
55
+ import type { startClaudeSession as StartClaudeSessionFn } from "./claude-session-start"
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Minimal structural interfaces — only the operations this module calls.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** Subset of OAuthTokenPool used by spawnClaudeTurn. */
62
+ interface SpawnOAuthPool {
63
+ pickActive(chatId: string): { id: string; token: string; label: string } | null | undefined
64
+ hasAnyToken(): boolean
65
+ markUsed(tokenId: string): void
66
+ release(chatId: string): void
67
+ }
68
+
69
+ /** Subset of EventStore used by spawnClaudeTurn (only for slash-command load). */
70
+ interface SpawnStore {
71
+ recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]): Promise<void>
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Exported arg types
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** Arguments for spawnClaudeTurn — mirrors the private startClaudeTurn args. */
79
+ export interface SpawnClaudeTurnArgs {
80
+ chatId: string
81
+ projectId: string
82
+ localPath: string
83
+ additionalDirectories?: string[]
84
+ stackProjects?: ResolvedStackBinding[]
85
+ model: string
86
+ effort?: string
87
+ planMode: boolean
88
+ sessionToken: string | null
89
+ forkSession: boolean
90
+ onToolRequest: (request: HarnessToolRequest) => Promise<AnyValue>
91
+ provider: AgentProvider
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Dependency bundle
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * All AgentCoordinator fields and methods accessed by spawnClaudeTurn.
100
+ *
101
+ * Mutable maps are passed directly; private methods are exposed as function
102
+ * references so the extracted function can be tested independently.
103
+ */
104
+ export interface SpawnClaudeTurnDeps {
105
+ // Mutable session state maps
106
+ claudeSessions: Map<string, ClaudeSessionState>
107
+ activeTurns: Map<string, ActiveTurn>
108
+ mentionedSubagentIdsByChat: Map<string, string[]>
109
+
110
+ // OAuth pool (structural subset)
111
+ oauthPool: SpawnOAuthPool | null
112
+
113
+ // Store (structural subset)
114
+ store: SpawnStore
115
+
116
+ // Session-start function references (injected so tests can stub them)
117
+ startClaudeSessionFn: typeof StartClaudeSessionFn
118
+ startClaudeSessionPTYFn: (args: StartClaudeSessionPtyArgs) => Promise<ClaudeSessionHandle>
119
+
120
+ // Opaque state references passed through to the start functions
121
+ subagentOrchestrator: SubagentOrchestrator
122
+ toolCallback: ToolCallbackService | null
123
+ tunnelGateway: TunnelGateway | null
124
+ claudePtyRegistry: ClaudePtyRegistry | null
125
+ ptyInstanceRegistry: PtyInstanceRegistry | null
126
+ workflowRegistry: WorkflowRegistry | null
127
+ subagentTranscriptRegistry: SubagentTranscriptRegistry | null
128
+
129
+ // Method references for private AgentCoordinator helpers
130
+ resolveClaudeDriverPreference: () => ClaudeDriverPreference
131
+ isLoopArmed: (chatId: string) => LoopState | null
132
+ closeClaudeSession: (chatId: string, session: ClaudeSessionState) => void
133
+ enforceClaudeSessionBudget: (protectedChatId?: string) => void
134
+ readLlmProvider: () => Promise<LlmProviderSnapshot>
135
+ buildPoolUnavailableMessage: (reservedFor: string, scopeSuffix: string) => string
136
+ listOpenRouterModelsFn: (() => Promise<OpenRouterModel[]>) | null
137
+ getSubagents: () => Subagent[]
138
+ getAppSettingsSnapshot: () => { globalPromptAppend?: string }
139
+ getEnabledCustomMcpServers: () => readonly McpServerConfig[]
140
+ buildOAuthBearers: (servers: readonly McpServerConfig[]) => Promise<Map<string, string>>
141
+ setupLoop: (chatId: string, input: LoopSetupInput) => Promise<SetupLoopHandlerResult>
142
+ stopLoop: (chatId: string, reason: "goal_met" | "user_send" | "chat_deleted") => Promise<void>
143
+ runOrchestration: (
144
+ chatId: string,
145
+ input: OrchRunInput,
146
+ ) => Promise<{ ok: true; runId: string } | { ok: false; errors: string[] }>
147
+ cancelOrchRun: (runId: string) => Promise<void>
148
+ getOrchRunDetail: (runId: string) => OrchRunDetail | null
149
+ resolveChatPolicy: (chatId: string) => ChatPermissionPolicy
150
+ /** Fires the session event loop. Return value is discarded (fire-and-forget). */
151
+ runClaudeSession: (session: ClaudeSessionState) => void
152
+ mergeLocalCatalog: (commands: SlashCommand[], cwd: string) => SlashCommand[]
153
+ emitStateChange: (chatId: string) => void
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Core function
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Spawns or reuses a Claude session for a chat turn.
162
+ *
163
+ * Extracted from AgentCoordinator.startClaudeTurn. Behavior is identical to
164
+ * the original private method; all side-effectful operations are injected via
165
+ * `deps`.
166
+ *
167
+ * Returns a HarnessTurn that delegates interrupt / getAccountInfo to the live
168
+ * session handle. The stream is empty (the event loop in runClaudeSession is
169
+ * the actual reader).
170
+ */
171
+ export async function spawnClaudeTurn(
172
+ deps: SpawnClaudeTurnDeps,
173
+ args: SpawnClaudeTurnArgs,
174
+ ): Promise<HarnessTurn> {
175
+ let session = deps.claudeSessions.get(args.chatId)
176
+
177
+ const driverIsPty = args.provider !== "openrouter"
178
+ && deps.resolveClaudeDriverPreference() === "pty"
179
+ const loopArmedNow = deps.isLoopArmed(args.chatId) !== null
180
+
181
+ if (
182
+ !session ||
183
+ session.localPath !== args.localPath ||
184
+ session.effort !== args.effort ||
185
+ args.forkSession ||
186
+ session.additionalDirectories.join("|") !== (args.additionalDirectories ?? []).join("|") ||
187
+ // Both drivers bake the loop tool-block into the spawn (PTY via
188
+ // --disallowedTools CLI args, SDK via options.disallowedTools), so an
189
+ // armed-state flip (arm OR disarm) requires a fresh session. The SDK's
190
+ // dynamic canUseTool deny remains as belt-and-suspenders mid-turn.
191
+ session.loopArmedAtSpawn !== loopArmedNow
192
+ ) {
193
+ if (session) {
194
+ deps.closeClaudeSession(args.chatId, session)
195
+ }
196
+
197
+ deps.enforceClaudeSessionBudget(args.chatId)
198
+ const isOpenRouter = args.provider === "openrouter"
199
+ const openrouterApiKey = isOpenRouter ? (await deps.readLlmProvider()).apiKey : null
200
+ const picked = isOpenRouter ? null : (deps.oauthPool?.pickActive(args.chatId) ?? null)
201
+ // If the pool is populated but every token is currently unusable
202
+ // (limited/error/disabled/reserved), refuse to spawn rather than let
203
+ // the CLI fall back to its keychain auth — that path serves whichever
204
+ // login the CLI binary's keychain holds, which is typically
205
+ // expired in a pool-managed setup and produces opaque 401 loops.
206
+ if (!isOpenRouter && deps.oauthPool && deps.oauthPool.hasAnyToken() && !picked) {
207
+ throw new OAuthPoolUnavailableError(deps.buildPoolUnavailableMessage(args.chatId, ""))
208
+ }
209
+ if (picked) deps.oauthPool!.markUsed(picked.id)
210
+
211
+ let openrouterTurnPrice: ModelPrice | null = null
212
+ let openrouterContextWindow: number | undefined
213
+ if (isOpenRouter && deps.listOpenRouterModelsFn) {
214
+ try {
215
+ const models = await deps.listOpenRouterModelsFn()
216
+ // OpenRouter routing variants (":nitro", ":floor", ...) aren't their
217
+ // own /models entries — fall back to the base id for pricing/context.
218
+ const baseModelId = stripModelVariantSuffix(args.model)
219
+ const m = models.find((x) => x.id === args.model)
220
+ ?? models.find((x) => x.id === baseModelId)
221
+ openrouterTurnPrice = resolveModelPrice(baseModelId, m?.pricing ?? null)
222
+ if (m && m.contextLength > 0) openrouterContextWindow = m.contextLength
223
+ } catch (err) {
224
+ log.warn("[kanna/agent] openrouter pricing lookup failed", String(err))
225
+ }
226
+ }
227
+
228
+ const usePty = driverIsPty
229
+ const systemPromptAppend = buildKannaSystemPromptAppend(deps.getSubagents(), {
230
+ globalPromptAppend: deps.getAppSettingsSnapshot().globalPromptAppend,
231
+ stackProjects: args.stackProjects,
232
+ })
233
+ const chatIdForCtx = args.chatId
234
+ const delegationContext: KannaMcpDelegationContext = {
235
+ parentSubagentId: null,
236
+ parentRunId: null,
237
+ ancestorSubagentIds: [],
238
+ depth: 0,
239
+ getParentUserMessageId: () => deps.activeTurns.get(chatIdForCtx)?.userMessageId ?? null,
240
+ getMentionedSubagentIds: () => deps.mentionedSubagentIdsByChat.get(chatIdForCtx) ?? [],
241
+ }
242
+ const enabledMcpServers = deps.getEnabledCustomMcpServers()
243
+ const oauthBearers = await deps.buildOAuthBearers(enabledMcpServers)
244
+ let started: ClaudeSessionHandle
245
+ try {
246
+ started = usePty
247
+ ? await deps.startClaudeSessionPTYFn({
248
+ chatId: args.chatId,
249
+ projectId: args.projectId,
250
+ localPath: args.localPath,
251
+ model: args.model,
252
+ effort: args.effort,
253
+ planMode: args.planMode,
254
+ sessionToken: args.sessionToken,
255
+ forkSession: args.forkSession,
256
+ oauthToken: picked?.token ?? null,
257
+ oauthLabel: picked?.label,
258
+ oauthKeyMasked: picked ? maskOauthKey(picked.token) : undefined,
259
+ additionalDirectories: args.additionalDirectories,
260
+ onToolRequest: args.onToolRequest,
261
+ systemPromptAppend,
262
+ subagentOrchestrator: deps.subagentOrchestrator,
263
+ delegationContext,
264
+ setupLoop: delegationContext.depth === 0
265
+ ? (input) => deps.setupLoop(chatIdForCtx, input)
266
+ : undefined,
267
+ stopLoop: delegationContext.depth === 0
268
+ ? () => deps.stopLoop(chatIdForCtx, "goal_met")
269
+ : undefined,
270
+ isLoopArmed: delegationContext.depth === 0
271
+ ? () => deps.isLoopArmed(chatIdForCtx) !== null
272
+ : undefined,
273
+ runOrch: delegationContext.depth === 0
274
+ ? (input) => deps.runOrchestration(chatIdForCtx, input)
275
+ : undefined,
276
+ cancelOrchRun: delegationContext.depth === 0
277
+ ? (runId) => deps.cancelOrchRun(runId)
278
+ : undefined,
279
+ getOrchRunStatus: delegationContext.depth === 0
280
+ ? (runId) => deps.getOrchRunDetail(runId)
281
+ : undefined,
282
+ toolCallback: deps.toolCallback ?? undefined,
283
+ tunnelGateway: deps.tunnelGateway,
284
+ chatPolicy: deps.resolveChatPolicy(args.chatId),
285
+ ptyRegistry: deps.claudePtyRegistry ?? undefined,
286
+ ptyInstanceRegistry: deps.ptyInstanceRegistry ?? undefined,
287
+ workflowRegistry: deps.workflowRegistry ?? undefined,
288
+ subagentTranscriptRegistry: deps.subagentTranscriptRegistry ?? undefined,
289
+ customMcpServers: enabledMcpServers,
290
+ oauthBearers,
291
+ })
292
+ : await deps.startClaudeSessionFn({
293
+ projectId: args.projectId,
294
+ localPath: args.localPath,
295
+ model: args.model,
296
+ effort: args.effort,
297
+ planMode: args.planMode,
298
+ sessionToken: args.sessionToken,
299
+ forkSession: args.forkSession,
300
+ oauthToken: picked?.token ?? null,
301
+ openrouterApiKey,
302
+ additionalDirectories: args.additionalDirectories,
303
+ chatId: args.chatId,
304
+ tunnelGateway: deps.tunnelGateway,
305
+ onToolRequest: args.onToolRequest,
306
+ systemPromptAppend,
307
+ subagentOrchestrator: deps.subagentOrchestrator,
308
+ delegationContext,
309
+ setupLoop: delegationContext.depth === 0
310
+ ? (input) => deps.setupLoop(chatIdForCtx, input)
311
+ : undefined,
312
+ stopLoop: delegationContext.depth === 0
313
+ ? () => deps.stopLoop(chatIdForCtx, "goal_met")
314
+ : undefined,
315
+ isLoopArmed: delegationContext.depth === 0
316
+ ? () => deps.isLoopArmed(chatIdForCtx) !== null
317
+ : undefined,
318
+ runOrch: delegationContext.depth === 0
319
+ ? (input) => deps.runOrchestration(chatIdForCtx, input)
320
+ : undefined,
321
+ cancelOrchRun: delegationContext.depth === 0
322
+ ? (runId) => deps.cancelOrchRun(runId)
323
+ : undefined,
324
+ getOrchRunStatus: delegationContext.depth === 0
325
+ ? (runId) => deps.getOrchRunDetail(runId)
326
+ : undefined,
327
+ toolCallback: deps.toolCallback ?? undefined,
328
+ chatPolicy: deps.resolveChatPolicy(args.chatId),
329
+ customMcpServers: enabledMcpServers,
330
+ oauthBearers,
331
+ turnPrice: openrouterTurnPrice,
332
+ contextWindowOverride: openrouterContextWindow,
333
+ })
334
+ } catch (err) {
335
+ // Spawn failed before we registered the session — release the OAuth
336
+ // pool reservation. Without this the token stays "in use" until process
337
+ // restart, eventually starving every chat once all tokens are reserved.
338
+ if (picked) deps.oauthPool?.release(args.chatId)
339
+ throw err
340
+ }
341
+
342
+ session = {
343
+ id: crypto.randomUUID(),
344
+ chatId: args.chatId,
345
+ session: started,
346
+ localPath: args.localPath,
347
+ additionalDirectories: args.additionalDirectories ?? [],
348
+ model: args.model,
349
+ effort: args.effort,
350
+ planMode: args.planMode,
351
+ sessionToken: args.sessionToken,
352
+ accountInfoLoaded: false,
353
+ nextPromptSeq: 0,
354
+ pendingPromptSeqs: [],
355
+ activeTokenId: picked?.id ?? null,
356
+ oauthKeyMasked: picked ? maskOauthKey(picked.token) : null,
357
+ oauthLabel: picked?.label ?? null,
358
+ openrouterKeyMasked: openrouterApiKey ? maskOauthKey(openrouterApiKey) : null,
359
+ openrouterModel: isOpenRouter ? args.model : null,
360
+ lastUsedAt: Date.now(),
361
+ backgroundTaskIds: new Set<string>(),
362
+ backgroundTaskDeadlineAt: 0,
363
+ loopArmedAtSpawn: loopArmedNow,
364
+ cancelledResultPending: 0,
365
+ suppressSessionTokenPersist: false,
366
+ }
367
+ deps.claudeSessions.set(args.chatId, session)
368
+ deps.enforceClaudeSessionBudget(args.chatId)
369
+ void deps.runClaudeSession(session)
370
+ void (async () => {
371
+ try {
372
+ const commands = await started.getSupportedCommands()
373
+ const merged = deps.mergeLocalCatalog(commands, args.localPath)
374
+ await deps.store.recordSessionCommandsLoaded(args.chatId, merged)
375
+ deps.emitStateChange(args.chatId)
376
+ } catch (error) {
377
+ log.warn("[kanna/agent] failed to load slash commands", String(error))
378
+ }
379
+ })()
380
+ } else {
381
+ session.lastUsedAt = Date.now()
382
+ if (session.model !== args.model) {
383
+ await session.session.setModel(args.model)
384
+ session.model = args.model
385
+ }
386
+ if (session.planMode !== args.planMode) {
387
+ await session.session.setPermissionMode(args.planMode)
388
+ session.planMode = args.planMode
389
+ }
390
+ }
391
+
392
+ return {
393
+ provider: "claude",
394
+ stream: {
395
+ async *[Symbol.asyncIterator]() {},
396
+ },
397
+ getAccountInfo: session.session.getAccountInfo,
398
+ interrupt: session.session.interrupt,
399
+ close: () => {},
400
+ }
401
+ }
@@ -0,0 +1,205 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // SDK mock — must be registered before the module under test is imported.
5
+ // Bun hoists mock.module() calls, so this executes before the top-level
6
+ // `import { query } from "@anthropic-ai/claude-agent-sdk"` in the source.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ type FakeQuery = {
10
+ accountInfo: () => Promise<unknown>
11
+ interrupt: () => Promise<void>
12
+ close: () => void
13
+ setModel: (m: string) => Promise<void>
14
+ setPermissionMode: (m: string) => Promise<void>
15
+ supportedCommands: () => Promise<unknown[]>
16
+ }
17
+
18
+ let mockQueryFn: (args: unknown) => FakeQuery
19
+ let capturedQueryArgs: unknown
20
+
21
+ const defaultFakeQ = (): FakeQuery => ({
22
+ accountInfo: async () => ({ provider: "claude", label: "test" }),
23
+ interrupt: async () => {},
24
+ close: () => {},
25
+ setModel: async () => {},
26
+ setPermissionMode: async () => {},
27
+ supportedCommands: async () => [],
28
+ })
29
+
30
+ mock.module("@anthropic-ai/claude-agent-sdk", () => ({
31
+ query: (args: unknown) => {
32
+ capturedQueryArgs = args
33
+ return mockQueryFn(args)
34
+ },
35
+ }))
36
+
37
+ // Mock kanna-mcp so we don't need a real MCP server
38
+ mock.module("./kanna-mcp", () => ({
39
+ createKannaMcpServer: () => ({ transport: "stdio", command: "echo", args: [] }),
40
+ }))
41
+
42
+ // NOTE: We do NOT mock.module("./claude-prompt-helpers") — toSdkEffort is pure
43
+ // and safe to use for real; the identity mock leaked into
44
+ // claude-prompt-helpers.test.ts under CI's file ordering.
45
+
46
+ // NOTE: We do NOT mock.module these siblings — that would pollute the global
47
+ // module registry and break their own test files that run later:
48
+ // ./claude-spawn-helpers, ./claude-harness-stream, ./claude-sdk-queue,
49
+ // ./claude-usage-math, ./claude-session-config (re-exported by agent.ts)
50
+ // Instead we inject fakes through the StartClaudeSessionDeps parameter.
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Import the module under test AFTER mock.module() registrations
54
+ // ---------------------------------------------------------------------------
55
+ import { startClaudeSession, type StartClaudeSessionDeps } from "./claude-session-start"
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Fake deps (replace the 4 sibling modules that must not be mock.module-d)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** Minimal queue fake: satisfies the structural AsyncMessageQueueCtor dep. */
62
+ class FakeQueue<T> implements AsyncIterable<T> {
63
+ private items: T[] = []
64
+ private _closed = false
65
+ push(item: T) {
66
+ if (this._closed) throw new Error("Cannot push to a closed queue")
67
+ this.items.push(item)
68
+ }
69
+ close() { this._closed = true }
70
+ get closed() { return this._closed }
71
+ get pushedItems() { return [...this.items] }
72
+ [Symbol.asyncIterator](): AsyncIterator<T, undefined> {
73
+ let i = 0
74
+ return {
75
+ next: async (): Promise<IteratorResult<T, undefined>> => {
76
+ if (i < this.items.length) return { done: false, value: this.items[i++] }
77
+ return { done: true, value: undefined }
78
+ },
79
+ }
80
+ }
81
+ }
82
+
83
+ function makeFakeDeps(): StartClaudeSessionDeps {
84
+ return {
85
+ buildCanUseTool: () => async () => ({ behavior: "allow" as const }),
86
+ buildClaudeEnv: (env) => env,
87
+ loopBlockedNativeTools: [] as readonly string[],
88
+ AsyncMessageQueueCtor: FakeQueue,
89
+ toClaudeMessageStream: () => (async function* () {})(),
90
+ createClaudeHarnessStream: () => (async function* () {})(),
91
+ parseConfiguredContextWindowFromModelId: () => undefined,
92
+ buildUserMcpServers: () => ({}),
93
+ claudeToolset: ["Bash", "Read", "Write"] as readonly string[],
94
+ sdkRestrictedFsNativeTools: ["Read", "Write"] as readonly string[],
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Minimal valid args reused across tests
100
+ // ---------------------------------------------------------------------------
101
+ const BASE_ARGS = {
102
+ projectId: "proj-1",
103
+ localPath: "/tmp/test",
104
+ model: "claude-opus-4-5",
105
+ planMode: false,
106
+ sessionToken: null,
107
+ forkSession: false,
108
+ oauthToken: null,
109
+ onToolRequest: async () => null,
110
+ }
111
+
112
+ beforeEach(() => {
113
+ mockQueryFn = defaultFakeQ
114
+ capturedQueryArgs = undefined
115
+ })
116
+
117
+ describe("startClaudeSession", () => {
118
+ // ── 1. Shape of returned handle ────────────────────────────────────────
119
+ test("returned handle has provider = 'claude'", async () => {
120
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
121
+ expect(handle.provider).toBe("claude")
122
+ })
123
+
124
+ test("returned handle exposes stream as async iterable", async () => {
125
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
126
+ expect(handle.stream).toBeDefined()
127
+ expect(typeof handle.stream[Symbol.asyncIterator]).toBe("function")
128
+ })
129
+
130
+ test("returned handle exposes sendPrompt, setModel, setPermissionMode, getSupportedCommands, interrupt, close", async () => {
131
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
132
+ expect(typeof handle.sendPrompt).toBe("function")
133
+ expect(typeof handle.setModel).toBe("function")
134
+ expect(typeof handle.setPermissionMode).toBe("function")
135
+ expect(typeof handle.getSupportedCommands).toBe("function")
136
+ expect(typeof handle.interrupt).toBe("function")
137
+ expect(typeof handle.close).toBe("function")
138
+ })
139
+
140
+ // ── 2. keepAlive behaviour ─────────────────────────────────────────────
141
+ test("handle has no pushChannelPrompt when keepAlive is not set", async () => {
142
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
143
+ expect(handle.pushChannelPrompt).toBeUndefined()
144
+ })
145
+
146
+ test("handle exposes pushChannelPrompt when keepAlive: true", async () => {
147
+ const handle = await startClaudeSession({ ...BASE_ARGS, keepAlive: true }, makeFakeDeps())
148
+ expect(typeof handle.pushChannelPrompt).toBe("function")
149
+ })
150
+
151
+ // ── 3. close() delegates to underlying query ───────────────────────────
152
+ test("close() can be called without throwing", async () => {
153
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
154
+ expect(() => handle.close()).not.toThrow()
155
+ })
156
+
157
+ // ── 4. getAccountInfo error-safety ─────────────────────────────────────
158
+ test("getAccountInfo returns null when underlying accountInfo() throws", async () => {
159
+ mockQueryFn = () => ({
160
+ ...defaultFakeQ(),
161
+ accountInfo: async () => { throw new Error("network error") },
162
+ })
163
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
164
+ const info = await handle.getAccountInfo?.()
165
+ expect(info).toBeNull()
166
+ })
167
+
168
+ // ── 5. getSupportedCommands error-safety ───────────────────────────────
169
+ test("getSupportedCommands returns [] when supportedCommands() throws", async () => {
170
+ mockQueryFn = () => ({
171
+ ...defaultFakeQ(),
172
+ supportedCommands: async () => { throw new Error("not ready") },
173
+ })
174
+ const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
175
+ const cmds = await handle.getSupportedCommands()
176
+ expect(cmds).toEqual([])
177
+ })
178
+
179
+ // ── 6. SDK options — model is forwarded ───────────────────────────────
180
+ test("SDK options include the model passed to startClaudeSession", async () => {
181
+ await startClaudeSession({ ...BASE_ARGS, model: "claude-sonnet-4-5" }, makeFakeDeps())
182
+ const args = capturedQueryArgs as { options: { model: string } }
183
+ expect(args.options.model).toBe("claude-sonnet-4-5")
184
+ })
185
+
186
+ // ── 7. SDK options — cwd is localPath ─────────────────────────────────
187
+ test("SDK cwd equals localPath", async () => {
188
+ await startClaudeSession({ ...BASE_ARGS, localPath: "/projects/myapp" }, makeFakeDeps())
189
+ const args = capturedQueryArgs as { options: { cwd: string } }
190
+ expect(args.options.cwd).toBe("/projects/myapp")
191
+ })
192
+
193
+ // ── 8. SDK options — planMode maps to permissionMode ──────────────────
194
+ test("planMode:true maps to permissionMode 'plan'", async () => {
195
+ await startClaudeSession({ ...BASE_ARGS, planMode: true }, makeFakeDeps())
196
+ const args = capturedQueryArgs as { options: { permissionMode: string } }
197
+ expect(args.options.permissionMode).toBe("plan")
198
+ })
199
+
200
+ test("planMode:false maps to permissionMode 'acceptEdits'", async () => {
201
+ await startClaudeSession({ ...BASE_ARGS, planMode: false }, makeFakeDeps())
202
+ const args = capturedQueryArgs as { options: { permissionMode: string } }
203
+ expect(args.options.permissionMode).toBe("acceptEdits")
204
+ })
205
+ })