kaizenai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- package/src/shared/types.ts +1028 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { promises as fs } from "node:fs"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join, resolve } from "node:path"
|
|
7
|
+
import { createInterface } from "node:readline"
|
|
8
|
+
import type { GeminiThinkingMode, NormalizedToolCall } from "../shared/types"
|
|
9
|
+
import { normalizeToolCall } from "../shared/tools"
|
|
10
|
+
import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
|
|
11
|
+
import {
|
|
12
|
+
AsyncQueue,
|
|
13
|
+
asRecord,
|
|
14
|
+
createResultEntry,
|
|
15
|
+
errorMessage,
|
|
16
|
+
isJsonRpcResponse,
|
|
17
|
+
normalizeAcpToolCall,
|
|
18
|
+
parseJsonLine,
|
|
19
|
+
stringifyToolCallContent,
|
|
20
|
+
timestamped,
|
|
21
|
+
type JsonRpcId,
|
|
22
|
+
type JsonRpcMessage,
|
|
23
|
+
type JsonRpcNotification,
|
|
24
|
+
type JsonRpcRequest,
|
|
25
|
+
type JsonRpcResponse,
|
|
26
|
+
type PendingRequest,
|
|
27
|
+
} from "./acp-shared"
|
|
28
|
+
|
|
29
|
+
interface GeminiSessionContext {
|
|
30
|
+
chatId: string
|
|
31
|
+
cwd: string
|
|
32
|
+
child: ChildProcess
|
|
33
|
+
settingsPath: string
|
|
34
|
+
pendingRequests: Map<JsonRpcId, PendingRequest<unknown>>
|
|
35
|
+
sessionId: string | null
|
|
36
|
+
initialized: boolean
|
|
37
|
+
loadedSessionId: string | null
|
|
38
|
+
currentModel: string | null
|
|
39
|
+
currentPlanMode: boolean | null
|
|
40
|
+
currentThinkingMode: GeminiThinkingMode | null
|
|
41
|
+
pendingTurn: PendingGeminiTurn | null
|
|
42
|
+
stderrLines: string[]
|
|
43
|
+
nextRequestId: number
|
|
44
|
+
closed: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PendingGeminiTurn {
|
|
48
|
+
queue: AsyncQueue<HarnessEvent>
|
|
49
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
50
|
+
pendingPermissionRequestId: JsonRpcId | null
|
|
51
|
+
replayMode: boolean
|
|
52
|
+
replayDrainTimer: ReturnType<typeof setTimeout> | null
|
|
53
|
+
replayDrainPromise: Promise<void> | null
|
|
54
|
+
replayDrainResolve: (() => void) | null
|
|
55
|
+
toolCalls: Map<string, NormalizedToolCall>
|
|
56
|
+
resultEmitted: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StartGeminiTurnArgs {
|
|
60
|
+
chatId: string
|
|
61
|
+
content: string
|
|
62
|
+
localPath: string
|
|
63
|
+
model: string
|
|
64
|
+
thinkingMode: GeminiThinkingMode
|
|
65
|
+
planMode: boolean
|
|
66
|
+
sessionToken: string | null
|
|
67
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shouldRespawnContext(context: GeminiSessionContext, args: StartGeminiTurnArgs) {
|
|
71
|
+
return (
|
|
72
|
+
context.cwd !== args.localPath ||
|
|
73
|
+
context.currentThinkingMode !== args.thinkingMode
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function modeIdFromPlanMode(planMode: boolean) {
|
|
78
|
+
return planMode ? "plan" : "yolo"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms: number) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createThinkingSettings(thinkingMode: GeminiThinkingMode, model: string) {
|
|
86
|
+
const isGemini3 = model.startsWith("gemini-3") || model === "auto-gemini-3"
|
|
87
|
+
const modelConfigs: Record<string, unknown> = {
|
|
88
|
+
customOverrides: [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!isGemini3) {
|
|
92
|
+
const thinkingBudget = thinkingMode === "off"
|
|
93
|
+
? 0
|
|
94
|
+
: thinkingMode === "high"
|
|
95
|
+
? 16384
|
|
96
|
+
: null
|
|
97
|
+
|
|
98
|
+
if (thinkingBudget !== null) {
|
|
99
|
+
modelConfigs.customOverrides = [
|
|
100
|
+
{
|
|
101
|
+
match: { model },
|
|
102
|
+
modelConfig: {
|
|
103
|
+
generateContentConfig: {
|
|
104
|
+
thinkingConfig: {
|
|
105
|
+
thinkingBudget,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
agents: {
|
|
116
|
+
overrides: {
|
|
117
|
+
codebase_investigator: {
|
|
118
|
+
enabled: false,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
modelConfigs,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractExitPlanPath(title: string | null | undefined) {
|
|
127
|
+
const planPath = (title ?? "").replace(/^Requesting plan approval for:\s*/i, "").trim()
|
|
128
|
+
return planPath || null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveGeminiPlanPath(sessionId: string | null, title: string | null | undefined) {
|
|
132
|
+
const explicitPlanPath = extractExitPlanPath(title)
|
|
133
|
+
if (explicitPlanPath) return explicitPlanPath
|
|
134
|
+
if (!sessionId) return null
|
|
135
|
+
|
|
136
|
+
const plansDir = join(homedir(), ".gemini", "tmp", "kaizen", sessionId, "plans")
|
|
137
|
+
try {
|
|
138
|
+
const entries = await fs.readdir(plansDir, { withFileTypes: true })
|
|
139
|
+
const markdownEntries = entries
|
|
140
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
141
|
+
.map((entry) => entry.name)
|
|
142
|
+
|
|
143
|
+
if (markdownEntries.length === 0) return null
|
|
144
|
+
|
|
145
|
+
const withStats = await Promise.all(markdownEntries.map(async (name) => {
|
|
146
|
+
const path = join(plansDir, name)
|
|
147
|
+
const stats = await fs.stat(path)
|
|
148
|
+
return { path, mtimeMs: stats.mtimeMs }
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
152
|
+
return withStats[0]?.path ?? null
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolvePlanPathFromDirectories(planDirectories: string[]) {
|
|
159
|
+
for (const plansDir of planDirectories) {
|
|
160
|
+
try {
|
|
161
|
+
const entries = await fs.readdir(plansDir, { withFileTypes: true })
|
|
162
|
+
const markdownEntries = entries
|
|
163
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
164
|
+
.map((entry) => entry.name)
|
|
165
|
+
|
|
166
|
+
if (markdownEntries.length === 0) continue
|
|
167
|
+
|
|
168
|
+
const withStats = await Promise.all(markdownEntries.map(async (name) => {
|
|
169
|
+
const path = join(plansDir, name)
|
|
170
|
+
const stats = await fs.stat(path)
|
|
171
|
+
return { path, mtimeMs: stats.mtimeMs }
|
|
172
|
+
}))
|
|
173
|
+
|
|
174
|
+
withStats.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
175
|
+
if (withStats[0]?.path) return withStats[0].path
|
|
176
|
+
} catch {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function enrichGeminiToolCall(
|
|
185
|
+
tool: NormalizedToolCall,
|
|
186
|
+
title: string | null | undefined,
|
|
187
|
+
sessionId: string | null
|
|
188
|
+
) {
|
|
189
|
+
if (tool.toolKind !== "exit_plan_mode" || tool.input.plan) {
|
|
190
|
+
return tool
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const planPath = await resolveGeminiPlanPath(sessionId, title)
|
|
194
|
+
?? await resolvePlanPathFromDirectories(
|
|
195
|
+
sessionId ? [join(homedir(), ".gemini", "tmp", "kaizen", sessionId, "plans")] : []
|
|
196
|
+
)
|
|
197
|
+
if (!planPath) return tool
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const plan = await fs.readFile(planPath, "utf8")
|
|
201
|
+
return normalizeToolCall({
|
|
202
|
+
toolName: "ExitPlanMode",
|
|
203
|
+
toolId: tool.toolId,
|
|
204
|
+
input: {
|
|
205
|
+
plan,
|
|
206
|
+
summary: planPath,
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
} catch {
|
|
210
|
+
return tool
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function prepareGeminiPrompt(content: string, planMode: boolean) {
|
|
215
|
+
if (!planMode) return content
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
"You are already in Gemini CLI Plan Mode.",
|
|
219
|
+
"Do not claim that enter_plan_mode is unavailable as a blocker.",
|
|
220
|
+
"Research the codebase, write the plan as a Markdown file in the designated Gemini plans directory, then call exit_plan_mode to request user approval.",
|
|
221
|
+
"Do not edit source files, do not implement the plan, and do not proceed past planning until the user explicitly approves the exit_plan_mode request.",
|
|
222
|
+
"",
|
|
223
|
+
content,
|
|
224
|
+
].join("\n")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isGeminiSessionPlanFile(filePath: string, cwd: string, sessionId: string | null) {
|
|
228
|
+
if (!sessionId || !filePath) return false
|
|
229
|
+
const resolvedPath = resolve(cwd, filePath)
|
|
230
|
+
const plansDir = join(homedir(), ".gemini", "tmp", "kaizen", sessionId, "plans")
|
|
231
|
+
return resolvedPath.startsWith(`${plansDir}/`) && resolvedPath.endsWith(".md")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isPlanModeMutationTool(tool: NormalizedToolCall, cwd: string, sessionId: string | null) {
|
|
235
|
+
if (tool.toolKind === "write_file") {
|
|
236
|
+
return !isGeminiSessionPlanFile(tool.input.filePath, cwd, sessionId)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (tool.toolKind === "edit_file") {
|
|
240
|
+
return !isGeminiSessionPlanFile(tool.input.filePath, cwd, sessionId)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
tool.toolKind === "bash" ||
|
|
245
|
+
tool.toolKind === "mcp_generic" ||
|
|
246
|
+
tool.toolKind === "subagent_task" ||
|
|
247
|
+
tool.toolKind === "unknown_tool"
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clearReplayDrainTimer(turn: PendingGeminiTurn) {
|
|
252
|
+
if (!turn.replayDrainTimer) return
|
|
253
|
+
clearTimeout(turn.replayDrainTimer)
|
|
254
|
+
turn.replayDrainTimer = null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function scheduleReplayDrain(turn: PendingGeminiTurn) {
|
|
258
|
+
clearReplayDrainTimer(turn)
|
|
259
|
+
turn.replayDrainTimer = setTimeout(() => {
|
|
260
|
+
turn.replayMode = false
|
|
261
|
+
turn.replayDrainResolve?.()
|
|
262
|
+
turn.replayDrainResolve = null
|
|
263
|
+
turn.replayDrainPromise = null
|
|
264
|
+
turn.replayDrainTimer = null
|
|
265
|
+
}, 150)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class GeminiAcpManager {
|
|
269
|
+
private readonly contexts = new Map<string, GeminiSessionContext>()
|
|
270
|
+
|
|
271
|
+
async startTurn(args: StartGeminiTurnArgs): Promise<HarnessTurn> {
|
|
272
|
+
let context = this.contexts.get(args.chatId)
|
|
273
|
+
if (context && shouldRespawnContext(context, args)) {
|
|
274
|
+
await this.disposeContext(context)
|
|
275
|
+
context = undefined
|
|
276
|
+
this.contexts.delete(args.chatId)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!context) {
|
|
280
|
+
context = await this.createContext(args)
|
|
281
|
+
this.contexts.set(args.chatId, context)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const queue = new AsyncQueue<HarnessEvent>()
|
|
285
|
+
const pendingTurn: PendingGeminiTurn = {
|
|
286
|
+
queue,
|
|
287
|
+
onToolRequest: args.onToolRequest,
|
|
288
|
+
pendingPermissionRequestId: null,
|
|
289
|
+
replayMode: false,
|
|
290
|
+
replayDrainTimer: null,
|
|
291
|
+
replayDrainPromise: null,
|
|
292
|
+
replayDrainResolve: null,
|
|
293
|
+
toolCalls: new Map(),
|
|
294
|
+
resultEmitted: false,
|
|
295
|
+
}
|
|
296
|
+
context.pendingTurn = pendingTurn
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await this.ensureSession(context, args)
|
|
300
|
+
queue.push({ type: "session_token", sessionToken: context.sessionId ?? undefined })
|
|
301
|
+
queue.push({
|
|
302
|
+
type: "transcript",
|
|
303
|
+
entry: timestamped({
|
|
304
|
+
kind: "system_init",
|
|
305
|
+
provider: "gemini",
|
|
306
|
+
model: args.model,
|
|
307
|
+
tools: [],
|
|
308
|
+
agents: [],
|
|
309
|
+
slashCommands: [],
|
|
310
|
+
mcpServers: [],
|
|
311
|
+
}),
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
if (context.currentModel !== args.model) {
|
|
315
|
+
await this.request(context, "session/set_model", {
|
|
316
|
+
sessionId: context.sessionId,
|
|
317
|
+
modelId: args.model,
|
|
318
|
+
})
|
|
319
|
+
context.currentModel = args.model
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const desiredMode = modeIdFromPlanMode(args.planMode)
|
|
323
|
+
if (context.currentPlanMode !== args.planMode) {
|
|
324
|
+
await this.request(context, "session/set_mode", {
|
|
325
|
+
sessionId: context.sessionId,
|
|
326
|
+
modeId: desiredMode,
|
|
327
|
+
})
|
|
328
|
+
context.currentPlanMode = args.planMode
|
|
329
|
+
await sleep(75)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const promptPromise = this.request<{ stopReason?: unknown }>(context, "session/prompt", {
|
|
333
|
+
sessionId: context.sessionId,
|
|
334
|
+
prompt: [
|
|
335
|
+
{
|
|
336
|
+
type: "text",
|
|
337
|
+
text: prepareGeminiPrompt(args.content, args.planMode),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
void promptPromise
|
|
343
|
+
.then((result) => {
|
|
344
|
+
if (pendingTurn.resultEmitted) return
|
|
345
|
+
pendingTurn.resultEmitted = true
|
|
346
|
+
pendingTurn.queue.push({
|
|
347
|
+
type: "transcript",
|
|
348
|
+
entry: createResultEntry(result),
|
|
349
|
+
})
|
|
350
|
+
pendingTurn.queue.finish()
|
|
351
|
+
})
|
|
352
|
+
.catch((error) => {
|
|
353
|
+
if (pendingTurn.resultEmitted) return
|
|
354
|
+
pendingTurn.resultEmitted = true
|
|
355
|
+
pendingTurn.queue.push({
|
|
356
|
+
type: "transcript",
|
|
357
|
+
entry: timestamped({
|
|
358
|
+
kind: "result",
|
|
359
|
+
subtype: "error",
|
|
360
|
+
isError: true,
|
|
361
|
+
durationMs: 0,
|
|
362
|
+
result: errorMessage(error),
|
|
363
|
+
}),
|
|
364
|
+
})
|
|
365
|
+
pendingTurn.queue.finish()
|
|
366
|
+
})
|
|
367
|
+
} catch (error) {
|
|
368
|
+
context.pendingTurn = null
|
|
369
|
+
queue.push({
|
|
370
|
+
type: "transcript",
|
|
371
|
+
entry: timestamped({
|
|
372
|
+
kind: "result",
|
|
373
|
+
subtype: "error",
|
|
374
|
+
isError: true,
|
|
375
|
+
durationMs: 0,
|
|
376
|
+
result: errorMessage(error),
|
|
377
|
+
}),
|
|
378
|
+
})
|
|
379
|
+
queue.finish()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
provider: "gemini",
|
|
384
|
+
stream: queue,
|
|
385
|
+
interrupt: async () => {
|
|
386
|
+
if (!context?.sessionId) return
|
|
387
|
+
try {
|
|
388
|
+
await this.notify(context, "session/cancel", { sessionId: context.sessionId })
|
|
389
|
+
} catch {
|
|
390
|
+
if (!context.child.killed) {
|
|
391
|
+
context.child.kill("SIGINT")
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
close: () => {
|
|
396
|
+
if (context?.pendingTurn === pendingTurn) {
|
|
397
|
+
context.pendingTurn = null
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
stopAll() {
|
|
404
|
+
for (const context of this.contexts.values()) {
|
|
405
|
+
void this.disposeContext(context)
|
|
406
|
+
}
|
|
407
|
+
this.contexts.clear()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async createContext(args: StartGeminiTurnArgs) {
|
|
411
|
+
const settingsPath = join(tmpdir(), `kaizen-gemini-settings-${randomUUID()}.json`)
|
|
412
|
+
await fs.writeFile(
|
|
413
|
+
settingsPath,
|
|
414
|
+
JSON.stringify(createThinkingSettings(args.thinkingMode, args.model))
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
const child = spawn("gemini", ["--acp"], {
|
|
418
|
+
cwd: args.localPath,
|
|
419
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
420
|
+
env: {
|
|
421
|
+
...process.env,
|
|
422
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath,
|
|
423
|
+
},
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const context: GeminiSessionContext = {
|
|
427
|
+
chatId: args.chatId,
|
|
428
|
+
cwd: args.localPath,
|
|
429
|
+
child,
|
|
430
|
+
settingsPath,
|
|
431
|
+
pendingRequests: new Map(),
|
|
432
|
+
sessionId: null,
|
|
433
|
+
initialized: false,
|
|
434
|
+
loadedSessionId: null,
|
|
435
|
+
currentModel: null,
|
|
436
|
+
currentPlanMode: null,
|
|
437
|
+
currentThinkingMode: args.thinkingMode,
|
|
438
|
+
pendingTurn: null,
|
|
439
|
+
stderrLines: [],
|
|
440
|
+
nextRequestId: 1,
|
|
441
|
+
closed: false,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const stdout = child.stdout
|
|
445
|
+
if (!stdout) throw new Error("Gemini ACP stdout is unavailable")
|
|
446
|
+
|
|
447
|
+
const rl = createInterface({ input: stdout })
|
|
448
|
+
rl.on("line", (line) => {
|
|
449
|
+
const message = parseJsonLine(line)
|
|
450
|
+
if (!message) return
|
|
451
|
+
void this.handleMessage(context, message)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const stderr = child.stderr
|
|
455
|
+
if (stderr) {
|
|
456
|
+
const stderrRl = createInterface({ input: stderr })
|
|
457
|
+
stderrRl.on("line", (line) => {
|
|
458
|
+
context.stderrLines.push(line)
|
|
459
|
+
const turn = context.pendingTurn
|
|
460
|
+
if (!turn || !line.trim()) return
|
|
461
|
+
turn.queue.push({
|
|
462
|
+
type: "transcript",
|
|
463
|
+
entry: timestamped({
|
|
464
|
+
kind: "status",
|
|
465
|
+
status: line.trim(),
|
|
466
|
+
}),
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
child.on("close", (code) => {
|
|
472
|
+
context.closed = true
|
|
473
|
+
for (const pending of context.pendingRequests.values()) {
|
|
474
|
+
pending.reject(new Error(`Gemini ACP exited with code ${code ?? "unknown"}`))
|
|
475
|
+
}
|
|
476
|
+
context.pendingRequests.clear()
|
|
477
|
+
|
|
478
|
+
const turn = context.pendingTurn
|
|
479
|
+
if (turn && !turn.resultEmitted) {
|
|
480
|
+
turn.resultEmitted = true
|
|
481
|
+
turn.queue.push({
|
|
482
|
+
type: "transcript",
|
|
483
|
+
entry: timestamped({
|
|
484
|
+
kind: "result",
|
|
485
|
+
subtype: "error",
|
|
486
|
+
isError: true,
|
|
487
|
+
durationMs: 0,
|
|
488
|
+
result: context.stderrLines.join("\n").trim() || `Gemini ACP exited with code ${code ?? "unknown"}`,
|
|
489
|
+
}),
|
|
490
|
+
})
|
|
491
|
+
turn.queue.finish()
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
child.on("error", (error) => {
|
|
496
|
+
const turn = context.pendingTurn
|
|
497
|
+
if (!turn || turn.resultEmitted) return
|
|
498
|
+
turn.resultEmitted = true
|
|
499
|
+
turn.queue.push({
|
|
500
|
+
type: "transcript",
|
|
501
|
+
entry: timestamped({
|
|
502
|
+
kind: "result",
|
|
503
|
+
subtype: "error",
|
|
504
|
+
isError: true,
|
|
505
|
+
durationMs: 0,
|
|
506
|
+
result: error.message.includes("ENOENT")
|
|
507
|
+
? "Gemini CLI not found. Install it with: npm install -g @google/gemini-cli"
|
|
508
|
+
: `Gemini ACP error: ${error.message}`,
|
|
509
|
+
}),
|
|
510
|
+
})
|
|
511
|
+
turn.queue.finish()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
await this.request(context, "initialize", {
|
|
515
|
+
protocolVersion: 1,
|
|
516
|
+
clientCapabilities: {},
|
|
517
|
+
})
|
|
518
|
+
context.initialized = true
|
|
519
|
+
|
|
520
|
+
return context
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async ensureSession(context: GeminiSessionContext, args: StartGeminiTurnArgs) {
|
|
524
|
+
if (args.sessionToken) {
|
|
525
|
+
if (context.loadedSessionId === args.sessionToken && context.sessionId === args.sessionToken) {
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
context.sessionId = args.sessionToken
|
|
530
|
+
context.loadedSessionId = args.sessionToken
|
|
531
|
+
const turn = context.pendingTurn
|
|
532
|
+
if (turn) {
|
|
533
|
+
turn.replayMode = true
|
|
534
|
+
turn.replayDrainPromise = new Promise<void>((resolve) => {
|
|
535
|
+
turn.replayDrainResolve = resolve
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
await this.request(context, "session/load", {
|
|
540
|
+
sessionId: args.sessionToken,
|
|
541
|
+
cwd: args.localPath,
|
|
542
|
+
mcpServers: [],
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
if (turn?.replayDrainPromise) {
|
|
546
|
+
scheduleReplayDrain(turn)
|
|
547
|
+
await turn.replayDrainPromise
|
|
548
|
+
}
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (context.sessionId) return
|
|
553
|
+
|
|
554
|
+
const result = await this.request<{ sessionId: string }>(context, "session/new", {
|
|
555
|
+
cwd: args.localPath,
|
|
556
|
+
mcpServers: [],
|
|
557
|
+
})
|
|
558
|
+
context.sessionId = typeof result.sessionId === "string" ? result.sessionId : null
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async handleMessage(context: GeminiSessionContext, message: JsonRpcMessage) {
|
|
562
|
+
if (isJsonRpcResponse(message)) {
|
|
563
|
+
const pending = context.pendingRequests.get(message.id)
|
|
564
|
+
if (!pending) return
|
|
565
|
+
context.pendingRequests.delete(message.id)
|
|
566
|
+
if (message.error) {
|
|
567
|
+
pending.reject(new Error(message.error.message))
|
|
568
|
+
} else {
|
|
569
|
+
pending.resolve(message.result)
|
|
570
|
+
}
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if ("id" in message && message.method === "session/request_permission") {
|
|
575
|
+
await this.handlePermissionRequest(context, message)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (message.method === "session/update") {
|
|
580
|
+
await this.handleSessionUpdate(context, asRecord(message.params))
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private async handlePermissionRequest(context: GeminiSessionContext, message: JsonRpcRequest) {
|
|
585
|
+
const params = asRecord(message.params)
|
|
586
|
+
const toolCall = asRecord(params?.toolCall)
|
|
587
|
+
const toolCallId = typeof toolCall?.toolCallId === "string" ? toolCall.toolCallId : randomUUID()
|
|
588
|
+
let normalizedTool = normalizeAcpToolCall({
|
|
589
|
+
toolCallId,
|
|
590
|
+
title: typeof toolCall?.title === "string" ? toolCall.title : undefined,
|
|
591
|
+
kind: typeof toolCall?.kind === "string" ? toolCall.kind : undefined,
|
|
592
|
+
locations: Array.isArray(toolCall?.locations) ? toolCall.locations as Array<{ path?: string | null }> : undefined,
|
|
593
|
+
content: Array.isArray(toolCall?.content) ? toolCall.content as Array<Record<string, unknown>> : undefined,
|
|
594
|
+
})
|
|
595
|
+
normalizedTool = await enrichGeminiToolCall(
|
|
596
|
+
normalizedTool,
|
|
597
|
+
typeof toolCall?.title === "string" ? toolCall.title : undefined,
|
|
598
|
+
context.sessionId
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
const turn = context.pendingTurn
|
|
602
|
+
if (!turn) {
|
|
603
|
+
await this.respondToPermissionRequest(context, message.id, { outcome: { outcome: "cancelled" } })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
turn.toolCalls.set(normalizedTool.toolId, normalizedTool)
|
|
608
|
+
turn.pendingPermissionRequestId = message.id
|
|
609
|
+
turn.queue.push({
|
|
610
|
+
type: "transcript",
|
|
611
|
+
entry: timestamped({
|
|
612
|
+
kind: "tool_call",
|
|
613
|
+
tool: normalizedTool,
|
|
614
|
+
}),
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
if (context.currentPlanMode && isPlanModeMutationTool(normalizedTool, context.cwd, context.sessionId)) {
|
|
618
|
+
turn.queue.push({
|
|
619
|
+
type: "transcript",
|
|
620
|
+
entry: timestamped({
|
|
621
|
+
kind: "tool_result",
|
|
622
|
+
toolId: normalizedTool.toolId,
|
|
623
|
+
content: "Blocked by Kaizen: Gemini cannot implement changes while plan mode is active. Write the plan, then call exit_plan_mode and wait for user approval.",
|
|
624
|
+
isError: true,
|
|
625
|
+
}),
|
|
626
|
+
})
|
|
627
|
+
await this.respondToPermissionRequest(context, message.id, { outcome: { outcome: "cancelled" } })
|
|
628
|
+
turn.pendingPermissionRequestId = null
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (normalizedTool.toolKind !== "ask_user_question" && normalizedTool.toolKind !== "exit_plan_mode") {
|
|
633
|
+
await this.respondToPermissionRequest(context, message.id, {
|
|
634
|
+
outcome: {
|
|
635
|
+
outcome: "selected",
|
|
636
|
+
optionId: this.defaultAllowOptionId(params),
|
|
637
|
+
},
|
|
638
|
+
})
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const rawResult = await turn.onToolRequest({
|
|
643
|
+
tool: normalizedTool as HarnessToolRequest["tool"],
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
const structuredResult = normalizedTool.toolKind === "exit_plan_mode"
|
|
647
|
+
? rawResult && typeof rawResult === "object"
|
|
648
|
+
? rawResult as Record<string, unknown>
|
|
649
|
+
: {}
|
|
650
|
+
: { answers: {} }
|
|
651
|
+
|
|
652
|
+
turn.queue.push({
|
|
653
|
+
type: "transcript",
|
|
654
|
+
entry: timestamped({
|
|
655
|
+
kind: "tool_result",
|
|
656
|
+
toolId: normalizedTool.toolId,
|
|
657
|
+
content: structuredResult,
|
|
658
|
+
}),
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
const confirmed = normalizedTool.toolKind === "exit_plan_mode"
|
|
662
|
+
? Boolean((structuredResult as Record<string, unknown>).confirmed)
|
|
663
|
+
: true
|
|
664
|
+
|
|
665
|
+
await this.respondToPermissionRequest(context, message.id, confirmed
|
|
666
|
+
? {
|
|
667
|
+
outcome: {
|
|
668
|
+
outcome: "selected",
|
|
669
|
+
optionId: this.defaultAllowOptionId(params),
|
|
670
|
+
},
|
|
671
|
+
}
|
|
672
|
+
: { outcome: { outcome: "cancelled" } })
|
|
673
|
+
|
|
674
|
+
turn.pendingPermissionRequestId = null
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private defaultAllowOptionId(params: Record<string, unknown> | null) {
|
|
678
|
+
const options = Array.isArray(params?.options) ? params.options : []
|
|
679
|
+
const allowOption = options.find((option) => {
|
|
680
|
+
const record = asRecord(option)
|
|
681
|
+
return record?.kind === "allow_once" && typeof record.optionId === "string"
|
|
682
|
+
})
|
|
683
|
+
if (allowOption && typeof (allowOption as Record<string, unknown>).optionId === "string") {
|
|
684
|
+
return (allowOption as Record<string, unknown>).optionId as string
|
|
685
|
+
}
|
|
686
|
+
const firstOptionId = asRecord(options[0])?.optionId
|
|
687
|
+
if (typeof firstOptionId === "string") return firstOptionId
|
|
688
|
+
return "allow_once"
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private async respondToPermissionRequest(context: GeminiSessionContext, id: JsonRpcId, result: unknown) {
|
|
692
|
+
await this.writeMessage(context, {
|
|
693
|
+
jsonrpc: "2.0",
|
|
694
|
+
id,
|
|
695
|
+
result,
|
|
696
|
+
} satisfies JsonRpcResponse)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private async handleSessionUpdate(context: GeminiSessionContext, params: Record<string, unknown> | null) {
|
|
700
|
+
const turn = context.pendingTurn
|
|
701
|
+
if (!turn) return
|
|
702
|
+
|
|
703
|
+
const update = asRecord(params?.update)
|
|
704
|
+
if (!update) return
|
|
705
|
+
|
|
706
|
+
const sessionUpdate = update.sessionUpdate
|
|
707
|
+
if (typeof sessionUpdate !== "string") return
|
|
708
|
+
|
|
709
|
+
if (turn.replayMode) {
|
|
710
|
+
scheduleReplayDrain(turn)
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (sessionUpdate === "agent_message_chunk") {
|
|
715
|
+
const content = asRecord(update.content)
|
|
716
|
+
if (content?.type === "text" && typeof content.text === "string") {
|
|
717
|
+
turn.queue.push({
|
|
718
|
+
type: "transcript",
|
|
719
|
+
entry: timestamped({
|
|
720
|
+
kind: "assistant_text",
|
|
721
|
+
text: content.text,
|
|
722
|
+
}),
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (sessionUpdate === "agent_thought_chunk") {
|
|
729
|
+
const content = asRecord(update.content)
|
|
730
|
+
if (content?.type === "text" && typeof content.text === "string") {
|
|
731
|
+
turn.queue.push({
|
|
732
|
+
type: "transcript",
|
|
733
|
+
entry: timestamped({
|
|
734
|
+
kind: "assistant_thought",
|
|
735
|
+
text: content.text,
|
|
736
|
+
}),
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (sessionUpdate === "tool_call") {
|
|
743
|
+
const toolCallId = typeof update.toolCallId === "string" ? update.toolCallId : randomUUID()
|
|
744
|
+
const normalizedTool = normalizeAcpToolCall({
|
|
745
|
+
toolCallId,
|
|
746
|
+
title: typeof update.title === "string" ? update.title : undefined,
|
|
747
|
+
kind: typeof update.kind === "string" ? update.kind : undefined,
|
|
748
|
+
locations: Array.isArray(update.locations) ? update.locations as Array<{ path?: string | null }> : undefined,
|
|
749
|
+
content: Array.isArray(update.content) ? update.content as Array<Record<string, unknown>> : undefined,
|
|
750
|
+
})
|
|
751
|
+
if (normalizedTool.toolKind === "ask_user_question" || normalizedTool.toolKind === "exit_plan_mode") {
|
|
752
|
+
turn.toolCalls.set(toolCallId, normalizedTool)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
turn.toolCalls.set(toolCallId, normalizedTool)
|
|
756
|
+
turn.queue.push({
|
|
757
|
+
type: "transcript",
|
|
758
|
+
entry: timestamped({
|
|
759
|
+
kind: "tool_call",
|
|
760
|
+
tool: normalizedTool,
|
|
761
|
+
}),
|
|
762
|
+
})
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (sessionUpdate === "tool_call_update") {
|
|
767
|
+
const toolCallId = typeof update.toolCallId === "string" ? update.toolCallId : randomUUID()
|
|
768
|
+
const content = Array.isArray(update.content) ? update.content as Array<Record<string, unknown>> : undefined
|
|
769
|
+
const status = typeof update.status === "string" ? update.status : undefined
|
|
770
|
+
const normalizedTool = turn.toolCalls.get(toolCallId)
|
|
771
|
+
if (status === "completed" || status === "failed") {
|
|
772
|
+
if (
|
|
773
|
+
normalizedTool?.toolKind === "ask_user_question" ||
|
|
774
|
+
normalizedTool?.toolKind === "exit_plan_mode"
|
|
775
|
+
) {
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
turn.queue.push({
|
|
779
|
+
type: "transcript",
|
|
780
|
+
entry: timestamped({
|
|
781
|
+
kind: "tool_result",
|
|
782
|
+
toolId: toolCallId,
|
|
783
|
+
content: stringifyToolCallContent(content),
|
|
784
|
+
isError: status === "failed",
|
|
785
|
+
}),
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private async request<TResult>(context: GeminiSessionContext, method: string, params?: unknown): Promise<TResult> {
|
|
792
|
+
const id = context.nextRequestId++
|
|
793
|
+
const promise = new Promise<TResult>((resolve, reject) => {
|
|
794
|
+
context.pendingRequests.set(id, {
|
|
795
|
+
method,
|
|
796
|
+
resolve: resolve as (value: unknown) => void,
|
|
797
|
+
reject,
|
|
798
|
+
})
|
|
799
|
+
})
|
|
800
|
+
await this.writeMessage(context, {
|
|
801
|
+
jsonrpc: "2.0",
|
|
802
|
+
id,
|
|
803
|
+
method,
|
|
804
|
+
params,
|
|
805
|
+
} satisfies JsonRpcRequest)
|
|
806
|
+
return await promise
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private async notify(context: GeminiSessionContext, method: string, params?: unknown) {
|
|
810
|
+
await this.writeMessage(context, {
|
|
811
|
+
jsonrpc: "2.0",
|
|
812
|
+
method,
|
|
813
|
+
params,
|
|
814
|
+
} satisfies JsonRpcNotification)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private async writeMessage(context: GeminiSessionContext, message: JsonRpcMessage) {
|
|
818
|
+
if (!context.child.stdin || context.child.stdin.destroyed) {
|
|
819
|
+
throw new Error("Gemini ACP stdin is unavailable")
|
|
820
|
+
}
|
|
821
|
+
await new Promise<void>((resolve, reject) => {
|
|
822
|
+
context.child.stdin!.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
823
|
+
if (error) {
|
|
824
|
+
reject(error)
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
resolve()
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private async disposeContext(context: GeminiSessionContext) {
|
|
833
|
+
context.closed = true
|
|
834
|
+
context.pendingTurn = null
|
|
835
|
+
for (const pending of context.pendingRequests.values()) {
|
|
836
|
+
pending.reject(new Error("Gemini ACP context disposed"))
|
|
837
|
+
}
|
|
838
|
+
context.pendingRequests.clear()
|
|
839
|
+
if (!context.child.killed) {
|
|
840
|
+
context.child.kill("SIGTERM")
|
|
841
|
+
}
|
|
842
|
+
await fs.rm(context.settingsPath, { force: true })
|
|
843
|
+
}
|
|
844
|
+
}
|