@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.
- package/dist/client/assets/{arc-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
- package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.test.ts +22 -6
- package/src/server/agent.ts +35 -4987
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2285
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +25 -1711
- package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.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
|
+
}
|