@cuongtran001/kanna 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
  29. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  33. package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
  34. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
  35. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  41. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  43. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  44. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  45. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  46. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  47. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  48. package/dist/client/index.html +1 -1
  49. package/package.json +1 -1
  50. package/src/server/agent-coordinator-types.ts +158 -0
  51. package/src/server/agent-coordinator.ts +919 -0
  52. package/src/server/agent-deps-builders.ts +425 -0
  53. package/src/server/agent.test.ts +22 -6
  54. package/src/server/agent.ts +35 -4987
  55. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  56. package/src/server/claude-autocontinue-commands.ts +256 -0
  57. package/src/server/claude-cancel-handler.test.ts +472 -0
  58. package/src/server/claude-cancel-handler.ts +241 -0
  59. package/src/server/claude-chat-management.test.ts +395 -0
  60. package/src/server/claude-chat-management.ts +251 -0
  61. package/src/server/claude-harness-stream.test.ts +273 -0
  62. package/src/server/claude-harness-stream.ts +158 -0
  63. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  64. package/src/server/claude-loop-orch-commands.ts +466 -0
  65. package/src/server/claude-message-normalizer.test.ts +213 -0
  66. package/src/server/claude-message-normalizer.ts +436 -0
  67. package/src/server/claude-prompt-helpers.test.ts +248 -0
  68. package/src/server/claude-prompt-helpers.ts +139 -0
  69. package/src/server/claude-sdk-queue.test.ts +169 -0
  70. package/src/server/claude-sdk-queue.ts +82 -0
  71. package/src/server/claude-send-command.test.ts +414 -0
  72. package/src/server/claude-send-command.ts +420 -0
  73. package/src/server/claude-session-config-helpers.test.ts +279 -0
  74. package/src/server/claude-session-config-helpers.ts +151 -0
  75. package/src/server/claude-session-config.test.ts +250 -0
  76. package/src/server/claude-session-config.ts +159 -0
  77. package/src/server/claude-session-error-handler.test.ts +498 -0
  78. package/src/server/claude-session-error-handler.ts +361 -0
  79. package/src/server/claude-session-lifecycle.test.ts +669 -0
  80. package/src/server/claude-session-lifecycle.ts +283 -0
  81. package/src/server/claude-session-rebuild.test.ts +224 -0
  82. package/src/server/claude-session-rebuild.ts +101 -0
  83. package/src/server/claude-session-runner.test.ts +523 -0
  84. package/src/server/claude-session-runner.ts +521 -0
  85. package/src/server/claude-session-spawner.test.ts +401 -0
  86. package/src/server/claude-session-spawner.ts +401 -0
  87. package/src/server/claude-session-start.test.ts +205 -0
  88. package/src/server/claude-session-start.ts +277 -0
  89. package/src/server/claude-session-state-queries.test.ts +344 -0
  90. package/src/server/claude-session-state-queries.ts +160 -0
  91. package/src/server/claude-session-state.ts +104 -0
  92. package/src/server/claude-slash-commands.test.ts +277 -0
  93. package/src/server/claude-slash-commands.ts +234 -0
  94. package/src/server/claude-spawn-helpers.test.ts +221 -0
  95. package/src/server/claude-spawn-helpers.ts +179 -0
  96. package/src/server/claude-steer-log.test.ts +243 -0
  97. package/src/server/claude-steer-log.ts +58 -0
  98. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  99. package/src/server/claude-subagent-tool-response.ts +165 -0
  100. package/src/server/claude-subagent-wiring.test.ts +263 -0
  101. package/src/server/claude-subagent-wiring.ts +324 -0
  102. package/src/server/claude-tool-respond.test.ts +321 -0
  103. package/src/server/claude-tool-respond.ts +142 -0
  104. package/src/server/claude-turn-runner.test.ts +310 -0
  105. package/src/server/claude-turn-runner.ts +228 -0
  106. package/src/server/claude-turn-starter.test.ts +266 -0
  107. package/src/server/claude-turn-starter.ts +566 -0
  108. package/src/server/claude-usage-math.test.ts +169 -0
  109. package/src/server/claude-usage-math.ts +132 -0
  110. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  111. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  112. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  113. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  114. package/src/server/diff-store-errors.ts +85 -0
  115. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  116. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  117. package/src/server/diff-store-io.adapter.ts +27 -0
  118. package/src/server/diff-store-parse.test.ts +212 -0
  119. package/src/server/diff-store-parse.ts +122 -0
  120. package/src/server/diff-store-state.ts +82 -0
  121. package/src/server/diff-store.ts +76 -1854
  122. package/src/server/event-store-apply.test.ts +280 -0
  123. package/src/server/event-store-apply.ts +132 -0
  124. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  125. package/src/server/event-store-chat-lifecycle.ts +441 -0
  126. package/src/server/event-store-entity-write.ts +356 -0
  127. package/src/server/event-store-helpers.test.ts +230 -0
  128. package/src/server/event-store-helpers.ts +235 -0
  129. package/src/server/event-store-init.ts +276 -0
  130. package/src/server/event-store-messages.adapter.test.ts +365 -0
  131. package/src/server/event-store-messages.adapter.ts +213 -0
  132. package/src/server/event-store-orch-subscription.ts +124 -0
  133. package/src/server/event-store-orch.test.ts +462 -0
  134. package/src/server/event-store-orch.ts +312 -0
  135. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  136. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  137. package/src/server/event-store-snapshot.test.ts +597 -0
  138. package/src/server/event-store-snapshot.ts +658 -0
  139. package/src/server/event-store-subagent.test.ts +408 -0
  140. package/src/server/event-store-subagent.ts +198 -0
  141. package/src/server/event-store-tool-requests.test.ts +210 -0
  142. package/src/server/event-store-tool-requests.ts +131 -0
  143. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  144. package/src/server/event-store-write-ops.test.ts +554 -0
  145. package/src/server/event-store-write-ops.ts +529 -0
  146. package/src/server/event-store.test.ts +35 -0
  147. package/src/server/event-store.ts +251 -2285
  148. package/src/server/harness-types.ts +15 -1
  149. package/src/server/oauth-errors.ts +19 -0
  150. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  151. package/src/server/ws-router-agent-ctrl.ts +137 -0
  152. package/src/server/ws-router-broadcast.ts +488 -0
  153. package/src/server/ws-router-chat.test.ts +461 -0
  154. package/src/server/ws-router-chat.ts +279 -0
  155. package/src/server/ws-router-defaults.test.ts +283 -0
  156. package/src/server/ws-router-defaults.ts +434 -0
  157. package/src/server/ws-router-diff.test.ts +167 -0
  158. package/src/server/ws-router-diff.ts +250 -0
  159. package/src/server/ws-router-envelope.ts +329 -0
  160. package/src/server/ws-router-misc.test.ts +422 -0
  161. package/src/server/ws-router-misc.ts +265 -0
  162. package/src/server/ws-router-orch.test.ts +190 -0
  163. package/src/server/ws-router-orch.ts +93 -0
  164. package/src/server/ws-router-project.test.ts +403 -0
  165. package/src/server/ws-router-project.ts +256 -0
  166. package/src/server/ws-router-push.test.ts +193 -0
  167. package/src/server/ws-router-push.ts +120 -0
  168. package/src/server/ws-router-settings.test.ts +173 -0
  169. package/src/server/ws-router-settings.ts +416 -0
  170. package/src/server/ws-router-skills.test.ts +194 -0
  171. package/src/server/ws-router-skills.ts +204 -0
  172. package/src/server/ws-router-utils.ts +224 -0
  173. package/src/server/ws-router.ts +339 -2254
  174. package/src/shared/app-settings-types.ts +365 -0
  175. package/src/shared/core-types.ts +27 -0
  176. package/src/shared/git-diff-types.ts +165 -0
  177. package/src/shared/mcp-types.ts +105 -0
  178. package/src/shared/provider-model-types.ts +437 -0
  179. package/src/shared/subagent-types.ts +198 -0
  180. package/src/shared/tool-call-types.ts +314 -0
  181. package/src/shared/transcript-types.ts +270 -0
  182. package/src/shared/types.ts +25 -1711
  183. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  184. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  185. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  186. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,277 @@
1
+ import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
+ import {
3
+ createKannaMcpServer,
4
+ type KannaMcpDelegationContext,
5
+ type SetupLoopHandlerResult,
6
+ } from "./kanna-mcp"
7
+ import type { LoopSetupInput } from "./loop-template"
8
+ import { KANNA_MCP_SERVER_NAME } from "../shared/tools"
9
+ import { homedir } from "node:os"
10
+ import type { McpServerConfig } from "../shared/types"
11
+ import { KANNA_SYSTEM_PROMPT_APPEND } from "../shared/kanna-system-prompt"
12
+ import {
13
+ buildCanUseTool,
14
+ buildClaudeEnv,
15
+ LOOP_BLOCKED_NATIVE_TOOLS,
16
+ } from "./claude-spawn-helpers"
17
+ import {
18
+ buildUserMcpServers,
19
+ CLAUDE_TOOLSET,
20
+ SDK_RESTRICTED_FS_NATIVE_TOOLS,
21
+ } from "./claude-session-config"
22
+ import { toSdkEffort } from "./claude-prompt-helpers"
23
+ import { AsyncMessageQueue, toClaudeMessageStream } from "./claude-sdk-queue"
24
+ import { createClaudeHarnessStream } from "./claude-harness-stream"
25
+ import { parseConfiguredContextWindowFromModelId } from "./claude-usage-math"
26
+ import { log } from "../shared/log"
27
+ import type { ClaudeSessionHandle, HarnessToolRequest } from "./harness-types"
28
+ import type { SubagentOrchestrator } from "./subagent-orchestrator"
29
+ import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
30
+ import type { ToolCallbackService } from "./tool-callback"
31
+ import type { ChatPermissionPolicy } from "../shared/permission-policy"
32
+ import type { OrchRunDetail, OrchRunInput } from "../shared/orchestration-types"
33
+ import type { ModelPrice } from "../shared/token-pricing"
34
+ import type { AnyValue } from "../shared/errors"
35
+
36
+ /**
37
+ * Injectable collaborators for startClaudeSession.
38
+ *
39
+ * Kept as a second parameter (with a production default via
40
+ * buildStartClaudeSessionDeps) so tests can supply fakes for the modules that
41
+ * would otherwise pollute Bun's global module registry via mock.module().
42
+ */
43
+ export type StartClaudeSessionDeps = {
44
+ readonly buildCanUseTool: typeof buildCanUseTool
45
+ readonly buildClaudeEnv: typeof buildClaudeEnv
46
+ readonly loopBlockedNativeTools: readonly string[]
47
+ /** Structural constructor type so both AsyncMessageQueue and test fakes satisfy it. */
48
+ readonly AsyncMessageQueueCtor: { new <T>(): { push(value: T): void; close(): void } & AsyncIterable<T> }
49
+ readonly toClaudeMessageStream: typeof toClaudeMessageStream
50
+ readonly createClaudeHarnessStream: typeof createClaudeHarnessStream
51
+ readonly parseConfiguredContextWindowFromModelId: typeof parseConfiguredContextWindowFromModelId
52
+ readonly buildUserMcpServers: typeof buildUserMcpServers
53
+ readonly claudeToolset: readonly string[]
54
+ readonly sdkRestrictedFsNativeTools: readonly string[]
55
+ }
56
+
57
+ /** Returns the real (production) dep implementations. */
58
+ export function buildStartClaudeSessionDeps(): StartClaudeSessionDeps {
59
+ return {
60
+ buildCanUseTool,
61
+ buildClaudeEnv,
62
+ loopBlockedNativeTools: LOOP_BLOCKED_NATIVE_TOOLS,
63
+ AsyncMessageQueueCtor: AsyncMessageQueue,
64
+ toClaudeMessageStream,
65
+ createClaudeHarnessStream,
66
+ parseConfiguredContextWindowFromModelId,
67
+ buildUserMcpServers,
68
+ claudeToolset: CLAUDE_TOOLSET,
69
+ sdkRestrictedFsNativeTools: SDK_RESTRICTED_FS_NATIVE_TOOLS,
70
+ }
71
+ }
72
+
73
+ export async function startClaudeSession(args: {
74
+ projectId: string
75
+ localPath: string
76
+ model: string
77
+ effort?: string
78
+ planMode: boolean
79
+ sessionToken: string | null
80
+ forkSession: boolean
81
+ oauthToken: string | null
82
+ /** When set, redirect the SDK to OpenRouter instead of Anthropic. */
83
+ openrouterApiKey?: string | null
84
+ additionalDirectories?: string[]
85
+ chatId?: string
86
+ tunnelGateway?: TunnelGateway | null
87
+ onToolRequest: (request: HarnessToolRequest) => Promise<AnyValue>
88
+ systemPromptAppend?: string
89
+ systemPromptOverride?: string
90
+ initialPrompt?: string
91
+ /** Routes AskUserQuestion/ExitPlanMode through tool-callback when KANNA_MCP_TOOL_CALLBACKS=1. */
92
+ toolCallback?: ToolCallbackService
93
+ /** Per-chat permission policy. Defaults to POLICY_DEFAULT if omitted. */
94
+ chatPolicy?: ChatPermissionPolicy
95
+ /** Orchestrator for delegate_subagent. Omit to hide the tool. */
96
+ subagentOrchestrator?: SubagentOrchestrator
97
+ /** Per-spawn delegation context (depth / ancestor chain / parentUserMessageId resolver). */
98
+ delegationContext?: KannaMcpDelegationContext
99
+ /** Enabled user MCP servers, merged into the SDK's mcpServers map. */
100
+ customMcpServers?: readonly McpServerConfig[]
101
+ /** Pre-resolved oauth bearer tokens keyed by server id (from ensureFreshMcpToken). */
102
+ oauthBearers?: ReadonlyMap<string, string>
103
+ /** Folder-restricted subagent: disallow native FS tools, allowlist mcp__kanna__*, per-run path-deny. */
104
+ restrictedAllowedPaths?: string[]
105
+ /** Backs the `setup_loop` MCP tool. Omit to hide the tool. */
106
+ setupLoop?: (input: LoopSetupInput) => Promise<SetupLoopHandlerResult>
107
+ /** Backs the `stop_loop` MCP tool. Omit to hide the tool. */
108
+ stopLoop?: () => Promise<void>
109
+ /** Live check: true while an autonomous loop is armed — blocks direct-edit native tools. */
110
+ isLoopArmed?: () => boolean
111
+ /** Backs the `orch_run` / `orch_run_status` / `orch_cancel_run` MCP tools. Main-chat only. */
112
+ runOrch?: (input: OrchRunInput) => Promise<{ ok: true; runId: string } | { ok: false; errors: string[] }>
113
+ cancelOrchRun?: (runId: string) => Promise<void>
114
+ getOrchRunStatus?: (runId: string) => OrchRunDetail | null
115
+ /**
116
+ * Agentic-turn bound passed natively to the SDK query() (Claude Code's
117
+ * per-agent frontmatter maxTurns analog): the SDK stops gracefully and
118
+ * keeps the accumulated output. Used by subagent spawns.
119
+ */
120
+ maxTurns?: number
121
+ /** When true, leave the prompt queue open after initialPrompt and expose pushChannelPrompt on the handle. */
122
+ keepAlive?: boolean
123
+ /** Per-turn price for computing cost when the provider doesn't report it (OpenRouter). */
124
+ turnPrice?: ModelPrice | null
125
+ /** Overrides the configured context window (OpenRouter model contextLength). */
126
+ contextWindowOverride?: number
127
+ },
128
+ _deps: StartClaudeSessionDeps = buildStartClaudeSessionDeps(),
129
+ ): Promise<ClaudeSessionHandle> {
130
+ const canUseTool = _deps.buildCanUseTool({
131
+ localPath: args.localPath,
132
+ chatId: args.chatId,
133
+ sessionToken: args.sessionToken,
134
+ onToolRequest: args.onToolRequest,
135
+ toolCallback: args.toolCallback,
136
+ chatPolicy: args.chatPolicy,
137
+ isLoopArmed: args.isLoopArmed,
138
+ })
139
+
140
+ const promptQueue = new _deps.AsyncMessageQueueCtor<SDKUserMessage>()
141
+
142
+ const q = query({
143
+ prompt: promptQueue,
144
+ options: {
145
+ cwd: args.localPath,
146
+ ...(args.additionalDirectories && args.additionalDirectories.length > 0
147
+ ? { additionalDirectories: args.additionalDirectories }
148
+ : {}),
149
+ model: args.model,
150
+ effort: toSdkEffort(args.effort),
151
+ resume: args.sessionToken ?? undefined,
152
+ forkSession: args.forkSession,
153
+ permissionMode: args.planMode ? "plan" : "acceptEdits",
154
+ canUseTool,
155
+ // Filter-at-spawn (Claude Code's filterToolsForAgent pattern): while a
156
+ // loop is armed the direct-edit tools are removed from the tool list the
157
+ // model sees, so it cannot even attempt them. The dynamic canUseTool
158
+ // deny stays as belt-and-suspenders; an armed-state flip respawns the
159
+ // session (see loopArmedAtSpawn in startClaudeTurn).
160
+ ...(args.isLoopArmed?.() ? { disallowedTools: [..._deps.loopBlockedNativeTools] } : {}),
161
+ // Per-agent turn bound, threaded from Subagent.maxTurns. The SDK emits
162
+ // a graceful stop at the limit — accumulated output is preserved,
163
+ // matching Claude Code's max_turns_reached semantics.
164
+ ...(args.maxTurns !== undefined ? { maxTurns: args.maxTurns } : {}),
165
+ tools: args.restrictedAllowedPaths && args.restrictedAllowedPaths.length > 0
166
+ ? _deps.claudeToolset.filter((t) => !new Set<string>(_deps.sdkRestrictedFsNativeTools).has(t))
167
+ : [..._deps.claudeToolset],
168
+ mcpServers: {
169
+ [KANNA_MCP_SERVER_NAME]: createKannaMcpServer({
170
+ projectId: args.projectId,
171
+ localPath: args.localPath,
172
+ chatId: args.chatId,
173
+ sessionId: args.sessionToken ?? undefined,
174
+ tunnelGateway: args.tunnelGateway ?? null,
175
+ toolCallback: args.toolCallback,
176
+ chatPolicy: args.chatPolicy,
177
+ subagentOrchestrator: args.subagentOrchestrator,
178
+ delegationContext: args.delegationContext,
179
+ restrictedAllowedPaths: args.restrictedAllowedPaths,
180
+ setupLoop: args.setupLoop,
181
+ stopLoop: args.stopLoop,
182
+ runOrch: args.runOrch,
183
+ cancelOrchRun: args.cancelOrchRun,
184
+ getOrchRunStatus: args.getOrchRunStatus,
185
+ }),
186
+ ..._deps.buildUserMcpServers(args.customMcpServers ?? [], args.oauthBearers),
187
+ },
188
+ systemPrompt: args.systemPromptOverride != null
189
+ ? args.systemPromptOverride
190
+ : {
191
+ type: "preset",
192
+ preset: "claude_code",
193
+ append: args.systemPromptAppend ?? KANNA_SYSTEM_PROMPT_APPEND,
194
+ },
195
+ settingSources: ["user", "project", "local"],
196
+ pathToClaudeCodeExecutable: process.env.CLAUDE_EXECUTABLE?.replace(/^~(?=\/|$)/, homedir()) || undefined,
197
+ env: _deps.buildClaudeEnv(process.env, args.oauthToken, args.openrouterApiKey ? { apiKey: args.openrouterApiKey } : null),
198
+ },
199
+ })
200
+
201
+ // Follow-up turns (sendPrompt + keep-alive pushChannelPrompt) share one
202
+ // queue-push policy so the two transports cannot drift apart.
203
+ const enqueueUserPrompt = (content: string) => {
204
+ promptQueue.push({
205
+ type: "user",
206
+ message: { role: "user", content },
207
+ parent_tool_use_id: null,
208
+ session_id: args.sessionToken ?? "",
209
+ })
210
+ }
211
+
212
+ if (args.initialPrompt != null) {
213
+ promptQueue.push({
214
+ type: "user",
215
+ message: {
216
+ role: "user",
217
+ content: args.initialPrompt,
218
+ },
219
+ parent_tool_use_id: null,
220
+ session_id: args.sessionToken ?? undefined,
221
+ })
222
+ if (!args.keepAlive) {
223
+ promptQueue.close()
224
+ }
225
+ }
226
+
227
+ return {
228
+ provider: "claude",
229
+ stream: _deps.createClaudeHarnessStream(
230
+ _deps.toClaudeMessageStream(q),
231
+ args.contextWindowOverride ?? _deps.parseConfiguredContextWindowFromModelId(args.model),
232
+ args.turnPrice ? () => args.turnPrice ?? null : undefined,
233
+ ),
234
+ getAccountInfo: async () => {
235
+ try {
236
+ return await q.accountInfo()
237
+ } catch {
238
+ return null
239
+ }
240
+ },
241
+ interrupt: async () => {
242
+ await q.interrupt()
243
+ },
244
+ sendPrompt: async (content: string) => {
245
+ enqueueUserPrompt(content)
246
+ },
247
+ setModel: async (model: string) => {
248
+ await q.setModel(model)
249
+ },
250
+ setPermissionMode: async (planMode: boolean) => {
251
+ await q.setPermissionMode(planMode ? "plan" : "acceptEdits")
252
+ },
253
+ getSupportedCommands: async () => {
254
+ try {
255
+ return await q.supportedCommands()
256
+ } catch (error) {
257
+ log.warn("[kanna/claude] supportedCommands failed", String(error))
258
+ return []
259
+ }
260
+ },
261
+ ...(args.keepAlive ? {
262
+ pushChannelPrompt: async (content: string) => {
263
+ enqueueUserPrompt(content)
264
+ },
265
+ } : {}),
266
+ close: () => {
267
+ promptQueue.close()
268
+ q.close()
269
+ // Do NOT cancel pending tool-callback records here. close() also fires
270
+ // on token rotation and idle-session sweep — both of which preserve
271
+ // the model's logical turn (it will resume / re-emit). Denying
272
+ // mid-turn used to mask the question prompt as a silent drop. Pending
273
+ // records are now reaped by the explicit chat.cancel / chat.delete
274
+ // paths in ws-router.ts and by recoverOnStartup on server boot.
275
+ },
276
+ }
277
+ }
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, mock } from "bun:test"
2
+ import {
3
+ getActiveStatuses,
4
+ getWaitStartedAtByChatId,
5
+ getPendingTool,
6
+ getDrainingChatIds,
7
+ getSlashCommandsLoadingChatIds,
8
+ getClaudeSessionStates,
9
+ isClaudeSessionIdle,
10
+ sweepIdleClaudeSessions,
11
+ type SessionStateQueryDeps,
12
+ } from "./claude-session-state-queries"
13
+ import type { ActiveTurn, ClaudeSessionState, PendingToolRequest } from "./claude-session-state"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function makeSession(overrides?: Partial<ClaudeSessionState>): ClaudeSessionState {
20
+ return {
21
+ id: "sess-1",
22
+ chatId: "chat-1",
23
+ session: {} as ClaudeSessionState["session"],
24
+ localPath: "/tmp/test",
25
+ additionalDirectories: [],
26
+ model: "claude-opus-4-5",
27
+ planMode: false,
28
+ sessionToken: null,
29
+ accountInfoLoaded: false,
30
+ nextPromptSeq: 0,
31
+ pendingPromptSeqs: [],
32
+ activeTokenId: null,
33
+ oauthKeyMasked: null,
34
+ oauthLabel: null,
35
+ openrouterKeyMasked: null,
36
+ openrouterModel: null,
37
+ lastUsedAt: Date.now(),
38
+ backgroundTaskIds: new Set(),
39
+ backgroundTaskDeadlineAt: 0,
40
+ loopArmedAtSpawn: false,
41
+ ...overrides,
42
+ } as ClaudeSessionState
43
+ }
44
+
45
+ function makeActiveTurn(overrides?: Partial<ActiveTurn>): ActiveTurn {
46
+ return {
47
+ chatId: "chat-1",
48
+ provider: "claude",
49
+ turn: {} as ActiveTurn["turn"],
50
+ model: "claude-opus-4-5",
51
+ planMode: false,
52
+ status: "running",
53
+ pendingTool: null,
54
+ postToolFollowUp: null,
55
+ hasFinalResult: false,
56
+ cancelRequested: false,
57
+ cancelRecorded: false,
58
+ waitStartedAt: null,
59
+ userMessageId: null,
60
+ ...overrides,
61
+ } as ActiveTurn
62
+ }
63
+
64
+ function makeDeps(overrides?: Partial<SessionStateQueryDeps>): SessionStateQueryDeps {
65
+ return {
66
+ activeTurns: new Map(),
67
+ claudeSessions: new Map(),
68
+ drainingStreams: new Map(),
69
+ slashCommandsInFlight: new Set(),
70
+ isClaudeSdkProvider: mock(() => false),
71
+ hasPendingBackgroundTask: mock(() => false),
72
+ resolveClaudeIdleMs: mock(() => 600_000),
73
+ hasLiveWorkflow: mock(() => false),
74
+ closeClaudeSession: mock(() => undefined),
75
+ emitStateChange: mock(() => undefined),
76
+ ...overrides,
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // getActiveStatuses
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe("getActiveStatuses", () => {
85
+ it("returns empty map when no active turns", () => {
86
+ const deps = makeDeps()
87
+ expect(getActiveStatuses(deps).size).toBe(0)
88
+ })
89
+
90
+ it("maps chatId → status for each active turn", () => {
91
+ const deps = makeDeps({
92
+ activeTurns: new Map([
93
+ ["chat-1", makeActiveTurn({ chatId: "chat-1", status: "running" })],
94
+ ["chat-2", makeActiveTurn({ chatId: "chat-2", status: "waiting_for_user" })],
95
+ ]),
96
+ })
97
+ const result = getActiveStatuses(deps)
98
+ expect(result.get("chat-1")).toBe("running")
99
+ expect(result.get("chat-2")).toBe("waiting_for_user")
100
+ })
101
+ })
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // getWaitStartedAtByChatId
105
+ // ---------------------------------------------------------------------------
106
+
107
+ describe("getWaitStartedAtByChatId", () => {
108
+ it("returns empty map when no turns are waiting", () => {
109
+ const deps = makeDeps({
110
+ activeTurns: new Map([["chat-1", makeActiveTurn({ waitStartedAt: null })]]),
111
+ })
112
+ expect(getWaitStartedAtByChatId(deps).size).toBe(0)
113
+ })
114
+
115
+ it("returns waitStartedAt only for turns with non-null value", () => {
116
+ const ts = 12345
117
+ const deps = makeDeps({
118
+ activeTurns: new Map([
119
+ ["chat-1", makeActiveTurn({ waitStartedAt: ts })],
120
+ ["chat-2", makeActiveTurn({ waitStartedAt: null })],
121
+ ]),
122
+ })
123
+ const result = getWaitStartedAtByChatId(deps)
124
+ expect(result.get("chat-1")).toBe(ts)
125
+ expect(result.has("chat-2")).toBe(false)
126
+ })
127
+ })
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // getPendingTool
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe("getPendingTool", () => {
134
+ it("returns null when chatId has no active turn", () => {
135
+ const deps = makeDeps()
136
+ expect(getPendingTool(deps, "chat-x")).toBeNull()
137
+ })
138
+
139
+ it("returns null when active turn has no pending tool", () => {
140
+ const deps = makeDeps({
141
+ activeTurns: new Map([["chat-1", makeActiveTurn({ pendingTool: null })]]),
142
+ })
143
+ expect(getPendingTool(deps, "chat-1")).toBeNull()
144
+ })
145
+
146
+ it("returns PendingToolSnapshot when pending tool is present", () => {
147
+ const deps = makeDeps({
148
+ activeTurns: new Map([
149
+ [
150
+ "chat-1",
151
+ makeActiveTurn({
152
+ pendingTool: {
153
+ toolUseId: "tool-123",
154
+ tool: { toolKind: "ask_user_question" } as PendingToolRequest["tool"],
155
+ resolve: () => undefined,
156
+ },
157
+ }),
158
+ ],
159
+ ]),
160
+ })
161
+ const result = getPendingTool(deps, "chat-1")
162
+ expect(result).toEqual({ toolUseId: "tool-123", toolKind: "ask_user_question" })
163
+ })
164
+ })
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // getDrainingChatIds
168
+ // ---------------------------------------------------------------------------
169
+
170
+ describe("getDrainingChatIds", () => {
171
+ it("returns empty set when no draining streams", () => {
172
+ const deps = makeDeps()
173
+ expect(getDrainingChatIds(deps).size).toBe(0)
174
+ })
175
+
176
+ it("returns chatIds of draining streams", () => {
177
+ const draining = new Map<string, unknown>([["chat-a", {}], ["chat-b", {}]])
178
+ const deps = makeDeps({ drainingStreams: draining as SessionStateQueryDeps["drainingStreams"] })
179
+ const result = getDrainingChatIds(deps)
180
+ expect(result.has("chat-a")).toBe(true)
181
+ expect(result.has("chat-b")).toBe(true)
182
+ expect(result.size).toBe(2)
183
+ })
184
+ })
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // getSlashCommandsLoadingChatIds
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe("getSlashCommandsLoadingChatIds", () => {
191
+ it("returns empty set when no slash commands in flight", () => {
192
+ const deps = makeDeps()
193
+ expect(getSlashCommandsLoadingChatIds(deps).size).toBe(0)
194
+ })
195
+
196
+ it("mirrors the slashCommandsInFlight set", () => {
197
+ const deps = makeDeps({ slashCommandsInFlight: new Set(["chat-1", "chat-2"]) })
198
+ const result = getSlashCommandsLoadingChatIds(deps)
199
+ expect(result.has("chat-1")).toBe(true)
200
+ expect(result.has("chat-2")).toBe(true)
201
+ })
202
+ })
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // getClaudeSessionStates
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("getClaudeSessionStates", () => {
209
+ it("returns empty map when no sessions", () => {
210
+ const deps = makeDeps()
211
+ expect(getClaudeSessionStates(deps).size).toBe(0)
212
+ })
213
+
214
+ it("returns 'active' for SDK providers", () => {
215
+ const session = makeSession({ chatId: "chat-1" })
216
+ const deps = makeDeps({
217
+ claudeSessions: new Map([["chat-1", session]]),
218
+ activeTurns: new Map([["chat-1", makeActiveTurn({ provider: "claude" })]]),
219
+ isClaudeSdkProvider: () => true,
220
+ })
221
+ expect(getClaudeSessionStates(deps).get("chat-1")).toBe("active")
222
+ })
223
+
224
+ it("returns 'warming' when background task pending", () => {
225
+ const session = makeSession({ chatId: "chat-1", lastUsedAt: 0 })
226
+ const deps = makeDeps({
227
+ claudeSessions: new Map([["chat-1", session]]),
228
+ hasPendingBackgroundTask: () => true,
229
+ resolveClaudeIdleMs: () => 100,
230
+ })
231
+ expect(getClaudeSessionStates(deps).get("chat-1")).toBe("warming")
232
+ })
233
+
234
+ it("returns 'idle' when idle timeout elapsed and no activity", () => {
235
+ const session = makeSession({ chatId: "chat-1", lastUsedAt: 0 })
236
+ const deps = makeDeps({
237
+ claudeSessions: new Map([["chat-1", session]]),
238
+ resolveClaudeIdleMs: () => 1, // 1 ms — already elapsed
239
+ })
240
+ expect(getClaudeSessionStates(deps).get("chat-1")).toBe("idle")
241
+ })
242
+
243
+ it("returns 'warming' when recently used and no activity", () => {
244
+ const session = makeSession({ chatId: "chat-1", lastUsedAt: Date.now() })
245
+ const deps = makeDeps({
246
+ claudeSessions: new Map([["chat-1", session]]),
247
+ resolveClaudeIdleMs: () => 600_000,
248
+ })
249
+ expect(getClaudeSessionStates(deps).get("chat-1")).toBe("warming")
250
+ })
251
+ })
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // isClaudeSessionIdle
255
+ // ---------------------------------------------------------------------------
256
+
257
+ describe("isClaudeSessionIdle", () => {
258
+ it("returns false when provider is an SDK provider", () => {
259
+ const session = makeSession({ lastUsedAt: 0 })
260
+ const deps = makeDeps({
261
+ activeTurns: new Map([["chat-1", makeActiveTurn({ provider: "claude" })]]),
262
+ isClaudeSdkProvider: () => true,
263
+ resolveClaudeIdleMs: () => 1,
264
+ })
265
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(false)
266
+ })
267
+
268
+ it("returns false when session has pending prompt seqs", () => {
269
+ const session = makeSession({ lastUsedAt: 0, pendingPromptSeqs: [1] })
270
+ const deps = makeDeps({ resolveClaudeIdleMs: () => 1 })
271
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(false)
272
+ })
273
+
274
+ it("returns false when chat has a live workflow", () => {
275
+ const session = makeSession({ lastUsedAt: 0 })
276
+ const deps = makeDeps({
277
+ hasLiveWorkflow: () => true,
278
+ resolveClaudeIdleMs: () => 1,
279
+ })
280
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(false)
281
+ })
282
+
283
+ it("returns false when background task is pending", () => {
284
+ const session = makeSession({ lastUsedAt: 0 })
285
+ const deps = makeDeps({
286
+ hasPendingBackgroundTask: () => true,
287
+ resolveClaudeIdleMs: () => 1,
288
+ })
289
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(false)
290
+ })
291
+
292
+ it("returns false when idle timeout has not elapsed", () => {
293
+ const session = makeSession({ lastUsedAt: Date.now() })
294
+ const deps = makeDeps({ resolveClaudeIdleMs: () => 600_000 })
295
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(false)
296
+ })
297
+
298
+ it("returns true when all idle conditions met", () => {
299
+ const session = makeSession({ lastUsedAt: 0, pendingPromptSeqs: [] })
300
+ const deps = makeDeps({ resolveClaudeIdleMs: () => 1 })
301
+ expect(isClaudeSessionIdle(deps, "chat-1", session, Date.now())).toBe(true)
302
+ })
303
+ })
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // sweepIdleClaudeSessions
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe("sweepIdleClaudeSessions", () => {
310
+ it("does nothing when no sessions", () => {
311
+ const deps = makeDeps()
312
+ sweepIdleClaudeSessions(deps, Date.now())
313
+ expect((deps.closeClaudeSession as ReturnType<typeof mock>).mock.calls.length).toBe(0)
314
+ })
315
+
316
+ it("closes and emits state change for idle sessions", () => {
317
+ const session = makeSession({ chatId: "chat-1", lastUsedAt: 0, pendingPromptSeqs: [] })
318
+ const closeFn = mock<(chatId: string, session: ClaudeSessionState) => void>(() => undefined)
319
+ const emitFn = mock<(chatId: string) => void>(() => undefined)
320
+ const deps = makeDeps({
321
+ claudeSessions: new Map([["chat-1", session]]),
322
+ resolveClaudeIdleMs: () => 1,
323
+ closeClaudeSession: closeFn,
324
+ emitStateChange: emitFn,
325
+ })
326
+ sweepIdleClaudeSessions(deps, Date.now())
327
+ expect(closeFn.mock.calls.length).toBe(1)
328
+ expect(closeFn.mock.calls[0]?.[0]).toBe("chat-1")
329
+ expect(emitFn.mock.calls.length).toBe(1)
330
+ expect(emitFn.mock.calls[0]?.[0]).toBe("chat-1")
331
+ })
332
+
333
+ it("does not close non-idle sessions", () => {
334
+ const session = makeSession({ chatId: "chat-1", lastUsedAt: Date.now(), pendingPromptSeqs: [] })
335
+ const closeFn = mock(() => undefined)
336
+ const deps = makeDeps({
337
+ claudeSessions: new Map([["chat-1", session]]),
338
+ resolveClaudeIdleMs: () => 600_000,
339
+ closeClaudeSession: closeFn,
340
+ })
341
+ sweepIdleClaudeSessions(deps, Date.now())
342
+ expect(closeFn.mock.calls.length).toBe(0)
343
+ })
344
+ })