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