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,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
|
+
}
|