@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,234 @@
1
+ /**
2
+ * Standalone slash-commands loader helpers for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the slash-command loading logic lives in its
5
+ * own testable module. The coordinator delegates to these functions by passing
6
+ * an object literal that satisfies `SlashCommandsDeps`.
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 { SlashCommand, Subagent, McpServerConfig, ClaudeDriverPreference } from "../shared/types"
14
+ import { resolveClaudeApiModelId } from "../shared/types"
15
+ import type { ClaudeSessionHandle, HarnessToolRequest } from "./harness-types"
16
+ import type { StartClaudeSessionPtyArgs } from "./claude-pty/driver"
17
+ import { log } from "../shared/log"
18
+ import { buildKannaSystemPromptAppend } from "../shared/kanna-system-prompt"
19
+ import { maskOauthKey } from "../shared/mask-oauth-key"
20
+ import { normalizeClaudeModelOptions, normalizeServerModel } from "./provider-catalog"
21
+ import type { AnyValue } from "../shared/errors"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Structural sub-interfaces — only the operations this module calls.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Subset of EventStore used by the slash-commands loader. */
28
+ interface SlashCommandsStore {
29
+ getChat(id: string):
30
+ | {
31
+ provider: string | null
32
+ slashCommands?: SlashCommand[] | null
33
+ planMode?: boolean | null
34
+ projectId: string
35
+ sessionTokensByProvider: { claude?: string | null }
36
+ }
37
+ | null
38
+ | undefined
39
+ getProject(id: string): { id: string; localPath: string } | null | undefined
40
+ recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]): Promise<void>
41
+ }
42
+
43
+ /** Subset of the active Claude sessions map. */
44
+ interface SlashCommandsSessionEntry {
45
+ session: { getSupportedCommands(): Promise<SlashCommand[]> }
46
+ }
47
+
48
+ /** Subset of OAuthTokenPool used by the slash-commands loader. */
49
+ interface SlashCommandsOAuthPool {
50
+ pickEphemeral(): { token: { id: string; token: string; label: string }; release(): void } | null
51
+ hasAnyToken(): boolean
52
+ markUsed(id: string): void
53
+ }
54
+
55
+ /** Subset of LocalCatalogService used by mergeLocalCatalog. */
56
+ interface SlashCommandsLocalCatalog {
57
+ list(cwd: string): SlashCommand[]
58
+ }
59
+
60
+ /** Minimal SDK session starter (only the args ensureSlashCommandsLoaded passes). */
61
+ type SlashCommandsSDKStarter = (args: {
62
+ projectId: string
63
+ localPath: string
64
+ model: string
65
+ effort?: string
66
+ planMode: boolean
67
+ sessionToken: string | null
68
+ forkSession: boolean
69
+ oauthToken: string | null
70
+ onToolRequest: (request: HarnessToolRequest) => Promise<AnyValue>
71
+ systemPromptAppend?: string
72
+ customMcpServers?: readonly McpServerConfig[]
73
+ }) => Promise<ClaudeSessionHandle>
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Dependency bundle injected by AgentCoordinator
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface SlashCommandsDeps {
80
+ store: SlashCommandsStore
81
+ claudeSessions: { get(chatId: string): SlashCommandsSessionEntry | undefined }
82
+ oauthPool: SlashCommandsOAuthPool | null
83
+ /** Mutable set of chatIds whose slash-command fetch is currently in-flight. */
84
+ slashCommandsInFlight: Set<string>
85
+ emitStateChange: (chatId: string) => void
86
+ resolveClaudeDriverPreference: () => ClaudeDriverPreference
87
+ startClaudeSessionPTY: (args: StartClaudeSessionPtyArgs) => Promise<ClaudeSessionHandle>
88
+ startClaudeSessionSDK: SlashCommandsSDKStarter
89
+ getSubagents: () => Subagent[]
90
+ getGlobalPromptAppend: () => string | undefined
91
+ getEnabledCustomMcpServers: () => readonly McpServerConfig[]
92
+ claudePtyRegistry:
93
+ | import("./claude-pty/pid-registry.adapter").ClaudePtyRegistry
94
+ | null
95
+ | undefined
96
+ ptyInstanceRegistry:
97
+ | import("./claude-pty/pty-instance-registry").PtyInstanceRegistry
98
+ | null
99
+ | undefined
100
+ workflowRegistry: import("./workflow-registry").WorkflowRegistry | null | undefined
101
+ subagentTranscriptRegistry:
102
+ | import("./subagent-transcript-registry").SubagentTranscriptRegistry
103
+ | null
104
+ | undefined
105
+ localCatalog: SlashCommandsLocalCatalog | null
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Exported functions
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Load the slash-command catalog for a chat session (no-op if already loaded
114
+ * or a load is in-flight). Spawns an ephemeral Claude session if the chat has
115
+ * no active session, reuses the existing one if it does.
116
+ */
117
+ export async function ensureSlashCommandsLoaded(
118
+ deps: SlashCommandsDeps,
119
+ chatId: string,
120
+ ): Promise<void> {
121
+ const chat = deps.store.getChat(chatId)
122
+ if (!chat) return
123
+ if (chat.provider === "codex") return
124
+ if (chat.slashCommands && chat.slashCommands.length > 0) return
125
+ if (deps.slashCommandsInFlight.has(chatId)) return
126
+
127
+ const project = deps.store.getProject(chat.projectId)
128
+ if (!project) return
129
+
130
+ deps.slashCommandsInFlight.add(chatId)
131
+ deps.emitStateChange(chatId)
132
+ try {
133
+ let commands: SlashCommand[]
134
+ const existing = deps.claudeSessions.get(chatId)
135
+ if (existing) {
136
+ commands = await existing.session.getSupportedCommands()
137
+ } else {
138
+ const defaultModel = normalizeServerModel("claude")
139
+ const defaultOptions = normalizeClaudeModelOptions(defaultModel)
140
+ // Ephemeral spawn: reserve under a synthetic key so two concurrent
141
+ // ensureSlashCommandsLoaded calls (different chats) cannot be handed
142
+ // the same token by lastUsedAt ordering. The lease MUST be released
143
+ // once the throwaway session closes (audit #2).
144
+ const lease = deps.oauthPool?.pickEphemeral() ?? null
145
+ // Skip the ephemeral spawn entirely when the pool has tokens but
146
+ // nothing is usable — avoids 401 against the CLI's keychain fallback
147
+ // and an opaque "supportedCommands failed" warning. Slash commands
148
+ // will load on the next turn once a token is available.
149
+ if (deps.oauthPool && deps.oauthPool.hasAnyToken() && !lease) {
150
+ return
151
+ }
152
+ const picked = lease?.token ?? null
153
+ if (picked) deps.oauthPool!.markUsed(picked.id)
154
+ const usePtyEphemeral = deps.resolveClaudeDriverPreference() === "pty"
155
+ const ephemeralSystemPromptAppend = buildKannaSystemPromptAppend(deps.getSubagents(), {
156
+ globalPromptAppend: deps.getGlobalPromptAppend(),
157
+ })
158
+ try {
159
+ const ephemeral = usePtyEphemeral
160
+ ? await deps.startClaudeSessionPTY({
161
+ chatId,
162
+ projectId: project.id,
163
+ localPath: project.localPath,
164
+ model: resolveClaudeApiModelId(defaultModel, defaultOptions.contextWindow),
165
+ effort: defaultOptions.reasoningEffort,
166
+ planMode: chat.planMode ?? false,
167
+ sessionToken: chat.sessionTokensByProvider.claude ?? null,
168
+ forkSession: false,
169
+ oauthToken: picked?.token ?? null,
170
+ oauthLabel: picked?.label,
171
+ oauthKeyMasked: picked ? maskOauthKey(picked.token) : undefined,
172
+ onToolRequest: async () => null,
173
+ systemPromptAppend: ephemeralSystemPromptAppend,
174
+ ptyRegistry: deps.claudePtyRegistry ?? undefined,
175
+ ptyInstanceRegistry: deps.ptyInstanceRegistry ?? undefined,
176
+ workflowRegistry: deps.workflowRegistry ?? undefined,
177
+ subagentTranscriptRegistry: deps.subagentTranscriptRegistry ?? undefined,
178
+ customMcpServers: deps.getEnabledCustomMcpServers(),
179
+ })
180
+ : await deps.startClaudeSessionSDK({
181
+ projectId: project.id,
182
+ localPath: project.localPath,
183
+ model: resolveClaudeApiModelId(defaultModel, defaultOptions.contextWindow),
184
+ effort: defaultOptions.reasoningEffort,
185
+ planMode: chat.planMode ?? false,
186
+ sessionToken: chat.sessionTokensByProvider.claude ?? null,
187
+ forkSession: false,
188
+ oauthToken: picked?.token ?? null,
189
+ onToolRequest: async () => null,
190
+ systemPromptAppend: ephemeralSystemPromptAppend,
191
+ customMcpServers: deps.getEnabledCustomMcpServers(),
192
+ })
193
+ try {
194
+ commands = await ephemeral.getSupportedCommands()
195
+ } finally {
196
+ ephemeral.close()
197
+ }
198
+ } finally {
199
+ lease?.release()
200
+ }
201
+ }
202
+ const merged = mergeLocalCatalog(deps, commands, project.localPath)
203
+ await deps.store.recordSessionCommandsLoaded(chatId, merged)
204
+ deps.emitStateChange(chatId)
205
+ } catch (error) {
206
+ log.warn("[kanna/agent] ensureSlashCommandsLoaded failed", String(error))
207
+ } finally {
208
+ deps.slashCommandsInFlight.delete(chatId)
209
+ deps.emitStateChange(chatId)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Merge the local command catalog (user/project scans) into the CLI-provided
215
+ * commands list. CLI entries take precedence; local entries with duplicate
216
+ * names (case-insensitive) are filtered out.
217
+ */
218
+ export function mergeLocalCatalog(
219
+ deps: Pick<SlashCommandsDeps, "localCatalog">,
220
+ commands: SlashCommand[],
221
+ cwd: string,
222
+ ): SlashCommand[] {
223
+ if (!deps.localCatalog) return commands
224
+ let local: SlashCommand[]
225
+ try {
226
+ local = deps.localCatalog.list(cwd)
227
+ } catch (error) {
228
+ log.warn("[kanna/agent] localCatalog.list failed", String(error))
229
+ return commands
230
+ }
231
+ const cliKeys = new Set(commands.map((c) => c.name.toLowerCase()))
232
+ const filtered = local.filter((entry) => !cliKeys.has(entry.name.toLowerCase()))
233
+ return [...commands, ...filtered]
234
+ }
@@ -0,0 +1,221 @@
1
+ import { describe, test, expect, mock } from "bun:test"
2
+ import {
3
+ LOOP_BLOCKED_NATIVE_TOOLS,
4
+ buildCanUseTool,
5
+ buildClaudeEnv,
6
+ type BuildCanUseToolArgs,
7
+ } from "./claude-spawn-helpers"
8
+ import type { ToolCallbackService } from "./tool-callback"
9
+ import type { AnyValue } from "../shared/errors"
10
+
11
+ // ── Minimal stubs ────────────────────────────────────────────────────────────
12
+
13
+ function makeArgs(overrides: Partial<BuildCanUseToolArgs> = {}): BuildCanUseToolArgs {
14
+ return {
15
+ localPath: "/tmp/test",
16
+ chatId: "chat-1",
17
+ sessionToken: "tok-1",
18
+ onToolRequest: async (_req) => ({ confirmed: true }),
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ const TOOL_OPTIONS = { toolUseID: "tool-use-1", signal: new AbortController().signal }
24
+
25
+ // ── LOOP_BLOCKED_NATIVE_TOOLS ────────────────────────────────────────────────
26
+
27
+ describe("LOOP_BLOCKED_NATIVE_TOOLS", () => {
28
+ test("contains the expected blocked tool names", () => {
29
+ expect(LOOP_BLOCKED_NATIVE_TOOLS).toContain("Edit")
30
+ expect(LOOP_BLOCKED_NATIVE_TOOLS).toContain("Write")
31
+ expect(LOOP_BLOCKED_NATIVE_TOOLS).toContain("MultiEdit")
32
+ expect(LOOP_BLOCKED_NATIVE_TOOLS).toContain("NotebookEdit")
33
+ expect(LOOP_BLOCKED_NATIVE_TOOLS).toContain("Task")
34
+ })
35
+ })
36
+
37
+ // ── buildCanUseTool ──────────────────────────────────────────────────────────
38
+
39
+ describe("buildCanUseTool", () => {
40
+ test("allows non-AskUserQuestion/ExitPlanMode tools unconditionally", async () => {
41
+ const canUseTool = buildCanUseTool(makeArgs())
42
+ const result = await canUseTool("Bash", { command: "ls" }, TOOL_OPTIONS)
43
+ expect(result.behavior).toBe("allow")
44
+ })
45
+
46
+ test("allows Read, Glob, Grep and other non-special tools", async () => {
47
+ const canUseTool = buildCanUseTool(makeArgs())
48
+ for (const tool of ["Read", "Glob", "Grep", "WebSearch"]) {
49
+ const result = await canUseTool(tool, {}, TOOL_OPTIONS)
50
+ expect(result.behavior).toBe("allow")
51
+ }
52
+ })
53
+
54
+ test("denies LOOP_BLOCKED_NATIVE_TOOLS when isLoopArmed() returns true", async () => {
55
+ const canUseTool = buildCanUseTool(makeArgs({ isLoopArmed: () => true }))
56
+ for (const tool of LOOP_BLOCKED_NATIVE_TOOLS) {
57
+ const result = await canUseTool(tool, {}, TOOL_OPTIONS)
58
+ expect(result.behavior).toBe("deny")
59
+ if (result.behavior === "deny") {
60
+ expect(result.message).toContain("autonomous loop is armed")
61
+ }
62
+ }
63
+ })
64
+
65
+ test("allows LOOP_BLOCKED_NATIVE_TOOLS when isLoopArmed() returns false", async () => {
66
+ const canUseTool = buildCanUseTool(makeArgs({ isLoopArmed: () => false }))
67
+ for (const tool of LOOP_BLOCKED_NATIVE_TOOLS) {
68
+ const result = await canUseTool(tool, {}, TOOL_OPTIONS)
69
+ expect(result.behavior).toBe("allow")
70
+ }
71
+ })
72
+
73
+ test("routes AskUserQuestion through legacy onToolRequest path and returns allow", async () => {
74
+ const onToolRequest = mock(async (_req: { tool: AnyValue }) => ({
75
+ answers: { q1: "yes" },
76
+ }))
77
+ const canUseTool = buildCanUseTool(makeArgs({ onToolRequest }))
78
+ const result = await canUseTool(
79
+ "AskUserQuestion",
80
+ { questions: [{ question: "Continue?", header: "Confirm", options: [], multiSelect: false }] },
81
+ TOOL_OPTIONS,
82
+ )
83
+ expect(result.behavior).toBe("allow")
84
+ expect(onToolRequest).toHaveBeenCalledTimes(1)
85
+ })
86
+
87
+ test("routes ExitPlanMode through legacy path — deny when confirmed is false", async () => {
88
+ const canUseTool = buildCanUseTool(
89
+ makeArgs({ onToolRequest: async () => ({ confirmed: false, message: "revise please" }) }),
90
+ )
91
+ const result = await canUseTool("ExitPlanMode", { plan: "the plan" }, TOOL_OPTIONS)
92
+ expect(result.behavior).toBe("deny")
93
+ if (result.behavior === "deny") {
94
+ expect(result.message).toContain("revise please")
95
+ }
96
+ })
97
+
98
+ test("routes ExitPlanMode through legacy path — allow when confirmed is true", async () => {
99
+ const canUseTool = buildCanUseTool(
100
+ makeArgs({ onToolRequest: async () => ({ confirmed: true }) }),
101
+ )
102
+ const result = await canUseTool("ExitPlanMode", { plan: "the plan" }, TOOL_OPTIONS)
103
+ expect(result.behavior).toBe("allow")
104
+ })
105
+
106
+ test("routes AskUserQuestion through toolCallback when KANNA_MCP_TOOL_CALLBACKS=1", async () => {
107
+ const originalEnv = process.env.KANNA_MCP_TOOL_CALLBACKS
108
+ process.env.KANNA_MCP_TOOL_CALLBACKS = "1"
109
+ try {
110
+ const toolCallback: ToolCallbackService = {
111
+ submit: mock(async () => ({
112
+ status: "answered" as const,
113
+ decision: {
114
+ kind: "allow" as const,
115
+ payload: { answers: { q1: "yes" }, questions: [] },
116
+ },
117
+ })),
118
+ answer: mock(async () => {}),
119
+ cancel: mock(async () => {}),
120
+ cancelAllForChat: mock(async () => {}),
121
+ recoverOnStartup: mock(async () => {}),
122
+ }
123
+ const canUseTool = buildCanUseTool(makeArgs({ toolCallback }))
124
+ const result = await canUseTool(
125
+ "AskUserQuestion",
126
+ { questions: [{ question: "OK?", header: "Check", options: [], multiSelect: false }] },
127
+ TOOL_OPTIONS,
128
+ )
129
+ expect(result.behavior).toBe("allow")
130
+ expect(toolCallback.submit).toHaveBeenCalledTimes(1)
131
+ } finally {
132
+ if (originalEnv === undefined) {
133
+ delete process.env.KANNA_MCP_TOOL_CALLBACKS
134
+ } else {
135
+ process.env.KANNA_MCP_TOOL_CALLBACKS = originalEnv
136
+ }
137
+ }
138
+ })
139
+
140
+ test("routes ExitPlanMode through toolCallback when KANNA_MCP_TOOL_CALLBACKS=1 — deny on unconfirmed", async () => {
141
+ const originalEnv = process.env.KANNA_MCP_TOOL_CALLBACKS
142
+ process.env.KANNA_MCP_TOOL_CALLBACKS = "1"
143
+ try {
144
+ const toolCallback: ToolCallbackService = {
145
+ submit: mock(async () => ({
146
+ status: "answered" as const,
147
+ decision: {
148
+ kind: "allow" as const,
149
+ payload: { confirmed: false, message: "needs changes" },
150
+ },
151
+ })),
152
+ answer: mock(async () => {}),
153
+ cancel: mock(async () => {}),
154
+ cancelAllForChat: mock(async () => {}),
155
+ recoverOnStartup: mock(async () => {}),
156
+ }
157
+ const canUseTool = buildCanUseTool(makeArgs({ toolCallback }))
158
+ const result = await canUseTool("ExitPlanMode", { plan: "the plan" }, TOOL_OPTIONS)
159
+ expect(result.behavior).toBe("deny")
160
+ if (result.behavior === "deny") {
161
+ expect(result.message).toContain("needs changes")
162
+ }
163
+ } finally {
164
+ if (originalEnv === undefined) {
165
+ delete process.env.KANNA_MCP_TOOL_CALLBACKS
166
+ } else {
167
+ process.env.KANNA_MCP_TOOL_CALLBACKS = originalEnv
168
+ }
169
+ }
170
+ })
171
+ })
172
+
173
+ // ── buildClaudeEnv ───────────────────────────────────────────────────────────
174
+
175
+ describe("buildClaudeEnv", () => {
176
+ test("strips CLAUDECODE and CLAUDE_CODE_OAUTH_TOKEN from base env when oauthToken provided", () => {
177
+ const base: NodeJS.ProcessEnv = {
178
+ PATH: "/usr/bin",
179
+ CLAUDECODE: "1",
180
+ CLAUDE_CODE_OAUTH_TOKEN: "old-token",
181
+ HOME: "/home/user",
182
+ }
183
+ const result = buildClaudeEnv(base, "new-token")
184
+ expect(result).not.toHaveProperty("CLAUDECODE")
185
+ expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe("new-token")
186
+ expect(result.PATH).toBe("/usr/bin")
187
+ expect(result.HOME).toBe("/home/user")
188
+ })
189
+
190
+ test("injects oauthToken as CLAUDE_CODE_OAUTH_TOKEN", () => {
191
+ const result = buildClaudeEnv({ PATH: "/bin" }, "my-oauth-token")
192
+ expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe("my-oauth-token")
193
+ })
194
+
195
+ test("sets ANTHROPIC_BASE_URL for OpenRouter and clears ANTHROPIC_API_KEY", () => {
196
+ const base: NodeJS.ProcessEnv = {
197
+ PATH: "/usr/bin",
198
+ ANTHROPIC_API_KEY: "sk-ant-existing",
199
+ CLAUDECODE: "1",
200
+ }
201
+ const result = buildClaudeEnv(base, null, { apiKey: "sk-or-key" })
202
+ expect(result.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api")
203
+ expect(result.ANTHROPIC_AUTH_TOKEN).toBe("sk-or-key")
204
+ expect(result.ANTHROPIC_API_KEY).toBe("")
205
+ expect(result).not.toHaveProperty("CLAUDECODE")
206
+ })
207
+
208
+ test("passes through non-oauth env when oauthToken is null and base has no token", () => {
209
+ const base: NodeJS.ProcessEnv = { PATH: "/bin", HOME: "/root" }
210
+ const result = buildClaudeEnv(base, null)
211
+ expect(result.PATH).toBe("/bin")
212
+ expect(result.HOME).toBe("/root")
213
+ expect(result).not.toHaveProperty("CLAUDE_CODE_OAUTH_TOKEN")
214
+ })
215
+
216
+ test("preserves existing CLAUDE_CODE_OAUTH_TOKEN from base when oauthToken is null", () => {
217
+ const base: NodeJS.ProcessEnv = { CLAUDE_CODE_OAUTH_TOKEN: "existing" }
218
+ const result = buildClaudeEnv(base, null)
219
+ expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe("existing")
220
+ })
221
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Spawn-preparation helpers for Claude SDK / PTY sessions.
3
+ *
4
+ * Pure module — no IO, no `*.adapter.ts` suffix needed.
5
+ * Extracted from agent.ts to keep that file under the 600-LOC target.
6
+ */
7
+
8
+ import type { CanUseTool, PermissionResult } from "@anthropic-ai/claude-agent-sdk"
9
+ import type { HarnessToolRequest } from "./harness-types"
10
+ import { normalizeToolCall } from "../shared/tools"
11
+ import { type AnyValue, isRecord } from "../shared/errors"
12
+ import type { ToolCallbackService } from "./tool-callback"
13
+ import type { ChatPermissionPolicy } from "../shared/permission-policy"
14
+ import { POLICY_DEFAULT } from "../shared/permission-policy"
15
+
16
+ /**
17
+ * Native tools blocked while an autonomous loop is armed on the chat. The loop
18
+ * orchestrator must delegate every code change to a subagent (fresh context
19
+ * each iteration); letting it edit directly is exactly the drift that produced
20
+ * the 7.5h marathon turn. `Task` (the native Agent tool) is blocked too — it
21
+ * runs inline in the same turn with no /clear.
22
+ */
23
+ export const LOOP_BLOCKED_NATIVE_TOOLS: readonly string[] = [
24
+ "Edit",
25
+ "Write",
26
+ "MultiEdit",
27
+ "NotebookEdit",
28
+ "Task",
29
+ ]
30
+
31
+ /** Args for the `buildCanUseTool` helper — exposed for unit testing. */
32
+ export interface BuildCanUseToolArgs {
33
+ localPath: string
34
+ chatId?: string
35
+ sessionToken?: string | null
36
+ onToolRequest: (request: HarnessToolRequest) => Promise<AnyValue>
37
+ toolCallback?: ToolCallbackService
38
+ chatPolicy?: ChatPermissionPolicy
39
+ /** When present and returns true, block LOOP_BLOCKED_NATIVE_TOOLS (loop-armed turn). */
40
+ isLoopArmed?: () => boolean
41
+ }
42
+
43
+ /**
44
+ * Builds the `canUseTool` callback passed to the SDK `query()`.
45
+ * Exported so unit tests can exercise the dual-routing logic without
46
+ * going through the full `startClaudeSession` factory.
47
+ */
48
+ export function buildCanUseTool(args: BuildCanUseToolArgs): CanUseTool {
49
+ return async (toolName, input, options) => {
50
+ // Loop-armed turns: the orchestrator may only Read/Bash(verify)/delegate.
51
+ // Block direct edits + the native Agent tool so it cannot self-implement.
52
+ if (args.isLoopArmed?.() && LOOP_BLOCKED_NATIVE_TOOLS.includes(toolName)) {
53
+ return {
54
+ behavior: "deny",
55
+ message:
56
+ `${toolName} is blocked while an autonomous loop is armed. You are the `
57
+ + "orchestrator: delegate the next chunk with delegate_subagent "
58
+ + "(run_in_background: true) and end your turn, or call stop_loop if the "
59
+ + "goal is met. Do not edit files directly.",
60
+ }
61
+ }
62
+
63
+ if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
64
+ return { behavior: "allow", updatedInput: input }
65
+ }
66
+
67
+ const tool = normalizeToolCall({
68
+ toolName,
69
+ toolId: options.toolUseID,
70
+ input: input ?? {},
71
+ })
72
+
73
+ if (tool.toolKind !== "ask_user_question" && tool.toolKind !== "exit_plan_mode") {
74
+ return { behavior: "deny", message: "Unsupported tool request" }
75
+ }
76
+
77
+ // ── Flag-on path: route through tool-callback ──────────────────────────
78
+ if (process.env.KANNA_MCP_TOOL_CALLBACKS === "1" && args.toolCallback) {
79
+ const result = await args.toolCallback.submit({
80
+ chatId: args.chatId ?? "",
81
+ sessionId: args.sessionToken ?? "",
82
+ toolUseId: options.toolUseID,
83
+ toolName: `mcp__kanna__${tool.toolKind}`,
84
+ args: isRecord(tool.rawInput) ? tool.rawInput : {},
85
+ chatPolicy: args.chatPolicy ?? POLICY_DEFAULT,
86
+ cwd: args.localPath,
87
+ })
88
+
89
+ if (result.decision.kind === "deny") {
90
+ return { behavior: "deny", message: result.decision.reason ?? "denied" }
91
+ }
92
+
93
+ const payload: Record<string, unknown> = isRecord(result.decision.payload) ? result.decision.payload : {}
94
+
95
+ if (tool.toolKind === "ask_user_question") {
96
+ return {
97
+ behavior: "allow",
98
+ updatedInput: {
99
+ ...(tool.rawInput ?? {}),
100
+ questions: payload.questions ?? tool.input.questions,
101
+ answers: payload.answers ?? result.decision.payload,
102
+ },
103
+ } satisfies PermissionResult
104
+ }
105
+
106
+ // exit_plan_mode
107
+ if (payload.confirmed) {
108
+ return {
109
+ behavior: "allow",
110
+ updatedInput: { ...(tool.rawInput ?? {}), ...payload },
111
+ } satisfies PermissionResult
112
+ }
113
+
114
+ return {
115
+ behavior: "deny",
116
+ message: typeof payload.message === "string"
117
+ ? `User wants to suggest edits to the plan: ${payload.message}`
118
+ : "User wants to suggest edits to the plan before approving.",
119
+ } satisfies PermissionResult
120
+ }
121
+
122
+ // ── Legacy path (flag off OR toolCallback not provided) ────────────────
123
+ const result = await args.onToolRequest({ tool })
124
+
125
+ if (tool.toolKind === "ask_user_question") {
126
+ const record: Record<string, unknown> = isRecord(result) ? result : {}
127
+ return {
128
+ behavior: "allow",
129
+ updatedInput: {
130
+ ...(tool.rawInput ?? {}),
131
+ questions: record.questions ?? tool.input.questions,
132
+ answers: record.answers ?? result,
133
+ },
134
+ } satisfies PermissionResult
135
+ }
136
+
137
+ const record: Record<string, unknown> = isRecord(result) ? result : {}
138
+ const confirmed = Boolean(record.confirmed)
139
+ if (confirmed) {
140
+ return {
141
+ behavior: "allow",
142
+ updatedInput: { ...(tool.rawInput ?? {}), ...record },
143
+ } satisfies PermissionResult
144
+ }
145
+
146
+ return {
147
+ behavior: "deny",
148
+ message: typeof record.message === "string"
149
+ ? `User wants to suggest edits to the plan: ${record.message}`
150
+ : "User wants to suggest edits to the plan before approving.",
151
+ } satisfies PermissionResult
152
+ }
153
+ }
154
+
155
+ export function buildClaudeEnv(
156
+ baseEnv: NodeJS.ProcessEnv,
157
+ oauthToken: string | null,
158
+ openrouter?: { apiKey: string } | null,
159
+ ): NodeJS.ProcessEnv {
160
+ const { CLAUDECODE: _unused, CLAUDE_CODE_OAUTH_TOKEN: _oauth, ...rest } = baseEnv
161
+ if (openrouter) {
162
+ // OpenRouter's Anthropic-compatible endpoint. ANTHROPIC_API_KEY MUST be
163
+ // explicitly empty or the SDK prefers it over the auth token and 401s.
164
+ return {
165
+ ...rest,
166
+ ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
167
+ ANTHROPIC_AUTH_TOKEN: openrouter.apiKey,
168
+ ANTHROPIC_API_KEY: "",
169
+ }
170
+ }
171
+ // Empty string is treated the same as null. Blank tokens are rejected at persistence time
172
+ // by normalizeTokenEntry, so in practice oauthToken is either a non-empty string or null.
173
+ if (!oauthToken) {
174
+ return baseEnv.CLAUDE_CODE_OAUTH_TOKEN
175
+ ? { ...rest, CLAUDE_CODE_OAUTH_TOKEN: baseEnv.CLAUDE_CODE_OAUTH_TOKEN }
176
+ : rest
177
+ }
178
+ return { ...rest, CLAUDE_CODE_OAUTH_TOKEN: oauthToken }
179
+ }