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.
Files changed (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. 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
+ }