kanna-code 0.41.7 → 0.42.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/dist/client/assets/{index-BujIAIt0.css → index-COpRUlCZ.css} +1 -1
- package/dist/client/assets/{index-BWrXLfF8.js → index-DDpLGxoy.js} +205 -205
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/{index-Bw6GI0aH.css → index-DH7NuXKA.css} +1 -1
- package/dist/export-viewer/assets/{index--p-u0wfg.js → index-DoK2sc-h.js} +51 -51
- package/dist/export-viewer/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +1 -8
- package/src/server/agent.ts +41 -22
- package/src/server/app-settings.test.ts +48 -2
- package/src/server/app-settings.ts +43 -8
- package/src/server/async-queue.ts +45 -0
- package/src/server/codex-app-server.test.ts +70 -1
- package/src/server/codex-app-server.ts +4 -62
- package/src/server/cursor-cli.test.ts +219 -0
- package/src/server/cursor-cli.ts +376 -0
- package/src/server/paths.ts +64 -0
- package/src/server/provider-catalog.test.ts +31 -4
- package/src/server/provider-catalog.ts +28 -22
- package/src/server/read-models.test.ts +17 -0
- package/src/server/read-models.ts +2 -0
- package/src/server/transcript.ts +14 -0
- package/src/server/ws-router.test.ts +7 -0
- package/src/server/ws-router.ts +26 -3
- package/src/shared/assert.ts +7 -0
- package/src/shared/git-url.ts +54 -0
- package/src/shared/json.ts +14 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.test.ts +50 -1
- package/src/shared/types.ts +150 -15
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { PassThrough } from "node:stream"
|
|
3
|
+
import { CursorCliManager, normalizeCursorUsage, parseCursorLine, type CursorChildProcess } from "./cursor-cli"
|
|
4
|
+
import type { HarnessEvent } from "./harness-types"
|
|
5
|
+
|
|
6
|
+
function transcriptEntries(events: HarnessEvent[]) {
|
|
7
|
+
return events.flatMap((event) => (event.type === "transcript" && event.entry ? [event.entry] : []))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function firstEntry(line: string, model = "composer-2.5") {
|
|
11
|
+
return transcriptEntries(parseCursorLine(line, model))[0]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("parseCursorLine", () => {
|
|
15
|
+
test("init emits a session token and a cursor system_init entry", () => {
|
|
16
|
+
const events = parseCursorLine(
|
|
17
|
+
`{"type":"system","subtype":"init","apiKeySource":"env","cwd":"/repo","session_id":"sess-1","model":"Composer 2.5 Fast"}`,
|
|
18
|
+
"composer-2.5-fast",
|
|
19
|
+
)
|
|
20
|
+
expect(events[0]).toEqual({ type: "session_token", sessionToken: "sess-1" })
|
|
21
|
+
// Uses the configured model id, not the display label ("Composer 2.5 Fast") from the event.
|
|
22
|
+
expect(transcriptEntries(events)[0]).toMatchObject({
|
|
23
|
+
kind: "system_init",
|
|
24
|
+
provider: "cursor",
|
|
25
|
+
model: "composer-2.5-fast",
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("assistant text becomes an assistant_text entry", () => {
|
|
30
|
+
const line = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the bug"}]},"session_id":"s"}`
|
|
31
|
+
expect(firstEntry(line)).toMatchObject({ kind: "assistant_text", text: "I found the bug" })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("shell tool call maps onto Bash", () => {
|
|
35
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-1","tool_call":{"shellToolCall":{"args":{"command":"ls -la","description":"List files"}}},"session_id":"s"}`
|
|
36
|
+
expect(firstEntry(line)).toMatchObject({
|
|
37
|
+
kind: "tool_call",
|
|
38
|
+
tool: { toolKind: "bash", toolId: "c-1", input: { command: "ls -la" } },
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("read tool call maps onto Read with the file path", () => {
|
|
43
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-2","tool_call":{"readToolCall":{"args":{"path":"/repo/note.txt"}}},"session_id":"s"}`
|
|
44
|
+
expect(firstEntry(line)).toMatchObject({
|
|
45
|
+
kind: "tool_call",
|
|
46
|
+
tool: { toolKind: "read_file", input: { filePath: "/repo/note.txt" } },
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("edit tool call maps Cursor's path/streamContent onto Write", () => {
|
|
51
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"c-3","tool_call":{"editToolCall":{"args":{"path":"/repo/calc.py","streamContent":"def add(a, b):\\n return a + b\\n"}}},"session_id":"s"}`
|
|
52
|
+
expect(firstEntry(line)).toMatchObject({
|
|
53
|
+
kind: "tool_call",
|
|
54
|
+
tool: { toolKind: "write_file", input: { filePath: "/repo/calc.py", content: expect.stringContaining("def add") } },
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("glob / grep / updateTodos map onto Glob / Grep / TodoWrite", () => {
|
|
59
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"g-1","tool_call":{"globToolCall":{"args":{"targetDirectory":"/repo","globPattern":"**/*.py"}}},"session_id":"s"}`))
|
|
60
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "glob", input: { pattern: "**/*.py" } } })
|
|
61
|
+
|
|
62
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"g-2","tool_call":{"grepToolCall":{"args":{"pattern":"def add","path":"/repo"}}},"session_id":"s"}`))
|
|
63
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "grep", input: { pattern: "def add" } } })
|
|
64
|
+
|
|
65
|
+
expect(firstEntry(`{"type":"tool_call","subtype":"started","call_id":"t-1","tool_call":{"updateTodosToolCall":{"args":{"todos":[{"content":"step","status":"pending"}]}}},"session_id":"s"}`))
|
|
66
|
+
.toMatchObject({ kind: "tool_call", tool: { toolKind: "todo_write", input: { todos: [{ content: "step", status: "pending" }] } } })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("an unmapped tool falls through to unknown_tool rather than being dropped", () => {
|
|
70
|
+
const line = `{"type":"tool_call","subtype":"started","call_id":"u-1","tool_call":{"mysteryToolCall":{"args":{"foo":"bar"}}},"session_id":"s"}`
|
|
71
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_call", tool: { toolKind: "unknown_tool" } })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("completed tool call becomes a tool_result keyed by call id", () => {
|
|
75
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-2","tool_call":{"readToolCall":{"args":{"path":"/repo/note.txt"},"result":{"success":{"content":"hello world\\n","isEmpty":false}}}},"session_id":"s"}`
|
|
76
|
+
expect(firstEntry(line)).toMatchObject({
|
|
77
|
+
kind: "tool_result",
|
|
78
|
+
toolId: "c-2",
|
|
79
|
+
isError: false,
|
|
80
|
+
content: { content: "hello world\n", isEmpty: false },
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("a failed tool call is flagged as an error result", () => {
|
|
85
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-9","tool_call":{"shellToolCall":{"args":{"command":"nope"},"result":{"error":{"message":"boom"}}}},"session_id":"s"}`
|
|
86
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_result", toolId: "c-9", isError: true })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("a completed tool with no structured result is not a false-positive error", () => {
|
|
90
|
+
const line = `{"type":"tool_call","subtype":"completed","call_id":"c-7","tool_call":{"shellToolCall":{"args":{"command":"ls"}}},"session_id":"s"}`
|
|
91
|
+
expect(firstEntry(line)).toMatchObject({ kind: "tool_result", toolId: "c-7", isError: false })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("result emits a context-window update followed by a success result", () => {
|
|
95
|
+
const line = `{"type":"result","subtype":"success","duration_ms":15609,"is_error":false,"result":"Done","session_id":"s","usage":{"inputTokens":17640,"outputTokens":138,"cacheReadTokens":27968,"cacheWriteTokens":0}}`
|
|
96
|
+
const entries = transcriptEntries(parseCursorLine(line, "composer-2.5"))
|
|
97
|
+
expect(entries.map((entry) => entry.kind)).toEqual(["context_window_updated", "result"])
|
|
98
|
+
expect(entries[1]).toMatchObject({ kind: "result", isError: false, subtype: "success", result: "Done", durationMs: 15609 })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("error result is surfaced as an error result entry", () => {
|
|
102
|
+
const line = `{"type":"result","subtype":"error","is_error":true,"result":"boom","session_id":"s"}`
|
|
103
|
+
const entries = transcriptEntries(parseCursorLine(line, "composer-2.5"))
|
|
104
|
+
expect(entries).toHaveLength(1)
|
|
105
|
+
expect(entries[0]).toMatchObject({ kind: "result", isError: true, subtype: "error", result: "boom" })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("prompt echo, reasoning, and malformed lines produce no entries", () => {
|
|
109
|
+
expect(parseCursorLine(`{"type":"user","message":{"content":[{"type":"text","text":"hi"}]}}`, "composer-2.5")).toEqual([])
|
|
110
|
+
expect(parseCursorLine(`{"type":"thinking","subtype":"delta"}`, "composer-2.5")).toEqual([])
|
|
111
|
+
expect(parseCursorLine("not json", "composer-2.5")).toEqual([])
|
|
112
|
+
expect(parseCursorLine("", "composer-2.5")).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("normalizeCursorUsage", () => {
|
|
117
|
+
test("sums direct + cache tokens into the context window snapshot", () => {
|
|
118
|
+
const usage = normalizeCursorUsage({ inputTokens: 17640, outputTokens: 138, cacheReadTokens: 27968, cacheWriteTokens: 0 })
|
|
119
|
+
expect(usage).toMatchObject({
|
|
120
|
+
inputTokens: 17640 + 27968,
|
|
121
|
+
cachedInputTokens: 27968,
|
|
122
|
+
outputTokens: 138,
|
|
123
|
+
usedTokens: 17640 + 27968 + 138,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("returns null when there is no usage", () => {
|
|
128
|
+
expect(normalizeCursorUsage(undefined)).toBeNull()
|
|
129
|
+
expect(normalizeCursorUsage({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Exercises the injectable `spawnProcess` seam: argv/stdin/resume wiring, stdout
|
|
134
|
+
// stream -> events, and the "process ended without a result" error synthesis.
|
|
135
|
+
function makeFakeChild() {
|
|
136
|
+
const stdin = new PassThrough()
|
|
137
|
+
const stdout = new PassThrough()
|
|
138
|
+
const stderr = new PassThrough()
|
|
139
|
+
let onClose: ((code: number | null) => void) | undefined
|
|
140
|
+
const child = {
|
|
141
|
+
stdin,
|
|
142
|
+
stdout,
|
|
143
|
+
stderr,
|
|
144
|
+
kill: () => true,
|
|
145
|
+
once(event: "close" | "error", listener: (arg: never) => void) {
|
|
146
|
+
if (event === "close") onClose = listener as unknown as (code: number | null) => void
|
|
147
|
+
return child
|
|
148
|
+
},
|
|
149
|
+
} as unknown as CursorChildProcess
|
|
150
|
+
return { child, stdin, stdout, stderr, close: (code: number | null) => onClose?.(code) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("CursorCliManager.startTurn", () => {
|
|
154
|
+
test("passes --force/--model/--resume and writes the prompt to stdin", async () => {
|
|
155
|
+
const fake = makeFakeChild()
|
|
156
|
+
let captured: { cwd: string; argv: string[] } | undefined
|
|
157
|
+
const manager = new CursorCliManager({ spawnProcess: (args) => { captured = args; return fake.child } })
|
|
158
|
+
|
|
159
|
+
const stdinText = new Promise<string>((resolve) => {
|
|
160
|
+
let data = ""
|
|
161
|
+
fake.stdin.on("data", (chunk) => { data += chunk.toString() })
|
|
162
|
+
fake.stdin.on("end", () => resolve(data))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "fix the bug", model: "composer-2.5-fast", sessionToken: "sess-9" })
|
|
166
|
+
|
|
167
|
+
expect(turn.provider).toBe("cursor")
|
|
168
|
+
expect(captured?.cwd).toBe("/repo")
|
|
169
|
+
expect(captured?.argv).toEqual([
|
|
170
|
+
"-p", "--output-format", "stream-json", "--force", "--model", "composer-2.5-fast", "--resume", "sess-9",
|
|
171
|
+
])
|
|
172
|
+
expect(await stdinText).toBe("fix the bug")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("omits --resume when there is no session token", async () => {
|
|
176
|
+
const fake = makeFakeChild()
|
|
177
|
+
let captured: { cwd: string; argv: string[] } | undefined
|
|
178
|
+
const manager = new CursorCliManager({ spawnProcess: (args) => { captured = args; return fake.child } })
|
|
179
|
+
await manager.startTurn({ cwd: "/repo", content: "hi", model: "composer-2.5", sessionToken: null })
|
|
180
|
+
expect(captured?.argv).not.toContain("--resume")
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("parses stdout NDJSON into events without synthesizing an error", async () => {
|
|
184
|
+
const fake = makeFakeChild()
|
|
185
|
+
const manager = new CursorCliManager({ spawnProcess: () => fake.child })
|
|
186
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "hi", model: "composer-2.5", sessionToken: null })
|
|
187
|
+
const iter = turn.stream[Symbol.asyncIterator]()
|
|
188
|
+
|
|
189
|
+
fake.stdout.write(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"session_id":"s"}\n`)
|
|
190
|
+
fake.stdout.write(`{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"s"}\n`)
|
|
191
|
+
|
|
192
|
+
// The consumer awaits each event as it is pushed, so this is deterministic without timers.
|
|
193
|
+
expect((await iter.next()).value).toMatchObject({ type: "transcript", entry: { kind: "assistant_text", text: "hi there" } })
|
|
194
|
+
expect((await iter.next()).value).toMatchObject({ type: "transcript", entry: { kind: "result", isError: false } })
|
|
195
|
+
|
|
196
|
+
fake.stdout.end()
|
|
197
|
+
fake.close(0)
|
|
198
|
+
expect((await iter.next()).done).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("synthesizes an error result (with stderr) when the process ends without one", async () => {
|
|
202
|
+
const fake = makeFakeChild()
|
|
203
|
+
const manager = new CursorCliManager({ spawnProcess: () => fake.child })
|
|
204
|
+
const turn = await manager.startTurn({ cwd: "/repo", content: "hi", model: "bad-model", sessionToken: null })
|
|
205
|
+
const iter = turn.stream[Symbol.asyncIterator]()
|
|
206
|
+
|
|
207
|
+
// Awaiting stderr's "end" guarantees the manager's "data" handler ran first.
|
|
208
|
+
fake.stderr.end("Cannot use this model: bad-model")
|
|
209
|
+
await new Promise<void>((resolve) => fake.stderr.once("end", resolve))
|
|
210
|
+
fake.stdout.end()
|
|
211
|
+
fake.close(1)
|
|
212
|
+
|
|
213
|
+
expect((await iter.next()).value).toMatchObject({
|
|
214
|
+
type: "transcript",
|
|
215
|
+
entry: { kind: "result", isError: true, subtype: "error", result: expect.stringContaining("Cannot use this model") },
|
|
216
|
+
})
|
|
217
|
+
expect((await iter.next()).done).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
import type { Readable, Writable } from "node:stream"
|
|
5
|
+
import type { ContextWindowUsageSnapshot } from "../shared/types"
|
|
6
|
+
import { asNumber, asRecord, asString } from "../shared/json"
|
|
7
|
+
import { normalizeToolCall } from "../shared/tools"
|
|
8
|
+
import type { HarnessEvent, HarnessTurn } from "./harness-types"
|
|
9
|
+
import { AsyncQueue } from "./async-queue"
|
|
10
|
+
import { timestamped } from "./transcript"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapter for the Cursor CLI (`cursor-agent` binary).
|
|
14
|
+
*
|
|
15
|
+
* Unlike Claude (SDK) and Codex (persistent JSON-RPC `app-server`), Cursor runs
|
|
16
|
+
* one headless process per turn:
|
|
17
|
+
*
|
|
18
|
+
* cursor-agent -p --output-format stream-json --force --model <id> [--resume <session>]
|
|
19
|
+
*
|
|
20
|
+
* with the prompt written to stdin. It emits NDJSON on stdout. `--force` is required
|
|
21
|
+
* in headless mode, otherwise the process blocks on a "Workspace Trust" prompt.
|
|
22
|
+
* Auth is via the CURSOR_API_KEY environment variable (inherited from the parent).
|
|
23
|
+
*
|
|
24
|
+
* Stream event types (one JSON object per line):
|
|
25
|
+
* - { type: "system", subtype: "init", session_id, model, cwd, apiKeySource }
|
|
26
|
+
* - { type: "user", ... } (prompt echo — ignored)
|
|
27
|
+
* - { type: "assistant", message: { content: [{ type: "text", text }] } }
|
|
28
|
+
* - { type: "thinking", subtype: "delta"|"completed" } (reasoning — ignored)
|
|
29
|
+
* - { type: "tool_call", subtype: "started"|"completed", call_id, tool_call: { <name>ToolCall: {...} } }
|
|
30
|
+
* - { type: "result", subtype: "success", is_error, duration_ms, result, session_id, usage }
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Minimal child-process surface so tests can inject a fake without rebuilding ChildProcess.
|
|
34
|
+
export interface CursorChildProcess {
|
|
35
|
+
readonly stdin: Writable | null
|
|
36
|
+
readonly stdout: Readable | null
|
|
37
|
+
readonly stderr: Readable | null
|
|
38
|
+
kill(signal?: NodeJS.Signals): boolean
|
|
39
|
+
once(event: "close", listener: (code: number | null) => void): unknown
|
|
40
|
+
once(event: "error", listener: (err: Error) => void): unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SpawnCursorAgent = (args: { cwd: string; argv: string[] }) => CursorChildProcess
|
|
44
|
+
|
|
45
|
+
export interface StartCursorTurnArgs {
|
|
46
|
+
cwd: string
|
|
47
|
+
content: string
|
|
48
|
+
/** Concrete model id to spawn, e.g. "composer-2.5" or "composer-2.5-fast". */
|
|
49
|
+
model: string
|
|
50
|
+
/** Previous Cursor session id to resume, if any. */
|
|
51
|
+
sessionToken: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map Cursor's token usage into Kanna's context-window snapshot. Mirrors
|
|
56
|
+
* `normalizeClaudeUsageSnapshot` in agent.ts. Cursor does not report a max
|
|
57
|
+
* context window, so `maxTokens` is left undefined.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeCursorUsage(value: unknown): ContextWindowUsageSnapshot | null {
|
|
60
|
+
const usage = asRecord(value)
|
|
61
|
+
if (!usage) return null
|
|
62
|
+
|
|
63
|
+
const directInputTokens = asNumber(usage.inputTokens) ?? 0
|
|
64
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens) ?? 0
|
|
65
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens) ?? 0
|
|
66
|
+
const outputTokens = asNumber(usage.outputTokens) ?? 0
|
|
67
|
+
|
|
68
|
+
const inputTokens = directInputTokens + cacheReadTokens + cacheWriteTokens
|
|
69
|
+
const usedTokens = inputTokens + outputTokens
|
|
70
|
+
if (usedTokens <= 0) return null
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
usedTokens,
|
|
74
|
+
inputTokens,
|
|
75
|
+
...(cacheReadTokens > 0 ? { cachedInputTokens: cacheReadTokens } : {}),
|
|
76
|
+
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
77
|
+
lastUsedTokens: usedTokens,
|
|
78
|
+
lastInputTokens: inputTokens,
|
|
79
|
+
...(cacheReadTokens > 0 ? { lastCachedInputTokens: cacheReadTokens } : {}),
|
|
80
|
+
...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}),
|
|
81
|
+
compactsAutomatically: false,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Translate a Cursor tool call into the Claude-style tool name + snake_case
|
|
87
|
+
* argument keys that `normalizeToolCall` understands, so tools render natively
|
|
88
|
+
* in the UI. Unknown tools fall through to `unknown_tool`.
|
|
89
|
+
*/
|
|
90
|
+
function translateCursorTool(
|
|
91
|
+
rawName: string,
|
|
92
|
+
args: Record<string, unknown>
|
|
93
|
+
): { toolName: string; input: Record<string, unknown> } {
|
|
94
|
+
// Tool keys and argument names are taken from observed `cursor-agent` stream
|
|
95
|
+
// output. Anything unmapped falls through to `unknown_tool`, which still renders.
|
|
96
|
+
switch (rawName.toLowerCase().replace(/[^a-z]/g, "")) {
|
|
97
|
+
case "shell":
|
|
98
|
+
return { toolName: "Bash", input: { command: args.command ?? "", description: args.description } }
|
|
99
|
+
case "read":
|
|
100
|
+
return { toolName: "Read", input: { file_path: args.path ?? "" } }
|
|
101
|
+
case "edit":
|
|
102
|
+
// Cursor's edit emits the full new file content (`streamContent`) rather than
|
|
103
|
+
// an old/new diff, so it maps cleanly onto Write.
|
|
104
|
+
return { toolName: "Write", input: { file_path: args.path ?? "", content: args.streamContent ?? "" } }
|
|
105
|
+
case "glob":
|
|
106
|
+
return { toolName: "Glob", input: { pattern: args.globPattern ?? "" } }
|
|
107
|
+
case "grep":
|
|
108
|
+
return { toolName: "Grep", input: { pattern: args.pattern ?? "" } }
|
|
109
|
+
case "updatetodos":
|
|
110
|
+
return { toolName: "TodoWrite", input: { todos: Array.isArray(args.todos) ? args.todos : [] } }
|
|
111
|
+
default:
|
|
112
|
+
return { toolName: rawName, input: args }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Cursor nests tool calls under keys like "shellToolCall", "readToolCall".
|
|
118
|
+
* Returns the raw tool name, its args, and the completed result (if present).
|
|
119
|
+
*/
|
|
120
|
+
function extractCursorTool(toolCall: unknown): {
|
|
121
|
+
rawName: string
|
|
122
|
+
args: Record<string, unknown>
|
|
123
|
+
result?: unknown
|
|
124
|
+
} {
|
|
125
|
+
const obj = asRecord(toolCall)
|
|
126
|
+
if (obj) {
|
|
127
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
128
|
+
if (k.endsWith("ToolCall")) {
|
|
129
|
+
const inner = asRecord(v)
|
|
130
|
+
return {
|
|
131
|
+
rawName: k.slice(0, -"ToolCall".length),
|
|
132
|
+
args: asRecord(inner?.args) ?? {},
|
|
133
|
+
result: inner?.result,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Fallback: { name, args }
|
|
138
|
+
const name = asString(obj.name)
|
|
139
|
+
if (name) {
|
|
140
|
+
return { rawName: name, args: asRecord(obj.args) ?? {}, result: obj.result }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { rawName: "unknown", args: {} }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractAssistantText(message: unknown): string {
|
|
147
|
+
const content = asRecord(message)?.content
|
|
148
|
+
if (!Array.isArray(content)) return ""
|
|
149
|
+
return content
|
|
150
|
+
.filter((item): item is Record<string, unknown> => asRecord(item)?.type === "text")
|
|
151
|
+
.map((item) => asString(item.text) ?? "")
|
|
152
|
+
.join("")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a single NDJSON line from `cursor-agent` into Kanna harness events.
|
|
157
|
+
* Pure and side-effect free so it can be unit tested against captured fixtures.
|
|
158
|
+
*/
|
|
159
|
+
export function parseCursorLine(line: string, configuredModel: string): HarnessEvent[] {
|
|
160
|
+
const trimmed = line.trim()
|
|
161
|
+
if (!trimmed) return []
|
|
162
|
+
|
|
163
|
+
let value: Record<string, unknown> | null
|
|
164
|
+
try {
|
|
165
|
+
value = asRecord(JSON.parse(trimmed))
|
|
166
|
+
} catch {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
if (!value) return []
|
|
170
|
+
|
|
171
|
+
const type = asString(value.type)
|
|
172
|
+
const debugRaw = trimmed
|
|
173
|
+
|
|
174
|
+
switch (type) {
|
|
175
|
+
case "system": {
|
|
176
|
+
if (asString(value.subtype) !== "init") return []
|
|
177
|
+
const events: HarnessEvent[] = []
|
|
178
|
+
const sessionId = asString(value.session_id)
|
|
179
|
+
if (sessionId) events.push({ type: "session_token", sessionToken: sessionId })
|
|
180
|
+
events.push({
|
|
181
|
+
type: "transcript",
|
|
182
|
+
entry: timestamped({
|
|
183
|
+
kind: "system_init",
|
|
184
|
+
provider: "cursor",
|
|
185
|
+
model: configuredModel,
|
|
186
|
+
tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "TodoWrite"],
|
|
187
|
+
agents: [],
|
|
188
|
+
slashCommands: [],
|
|
189
|
+
mcpServers: [],
|
|
190
|
+
debugRaw,
|
|
191
|
+
}),
|
|
192
|
+
})
|
|
193
|
+
return events
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "assistant": {
|
|
197
|
+
const text = extractAssistantText(value.message)
|
|
198
|
+
if (!text) return []
|
|
199
|
+
return [{ type: "transcript", entry: timestamped({ kind: "assistant_text", text, debugRaw }) }]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "tool_call": {
|
|
203
|
+
const subtype = asString(value.subtype)
|
|
204
|
+
const callId = asString(value.call_id) ?? randomUUID()
|
|
205
|
+
const { rawName, args, result } = extractCursorTool(value.tool_call)
|
|
206
|
+
|
|
207
|
+
if (subtype === "started") {
|
|
208
|
+
const { toolName, input } = translateCursorTool(rawName, args)
|
|
209
|
+
return [
|
|
210
|
+
{
|
|
211
|
+
type: "transcript",
|
|
212
|
+
entry: timestamped({
|
|
213
|
+
kind: "tool_call",
|
|
214
|
+
tool: normalizeToolCall({ toolName, toolId: callId, input }),
|
|
215
|
+
debugRaw,
|
|
216
|
+
}),
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (subtype === "completed") {
|
|
222
|
+
// Only flag an error when Cursor explicitly reports one. A missing/non-object
|
|
223
|
+
// result (e.g. a bare string) is treated as success rather than a false error.
|
|
224
|
+
const resultRecord = asRecord(result)
|
|
225
|
+
const isError = Boolean(resultRecord && ("error" in resultRecord || "failure" in resultRecord))
|
|
226
|
+
const content = resultRecord?.success ?? resultRecord?.error ?? resultRecord?.failure ?? result
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
type: "transcript",
|
|
230
|
+
entry: timestamped({
|
|
231
|
+
kind: "tool_result",
|
|
232
|
+
toolId: callId,
|
|
233
|
+
content,
|
|
234
|
+
isError,
|
|
235
|
+
debugRaw,
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return []
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "result": {
|
|
245
|
+
const events: HarnessEvent[] = []
|
|
246
|
+
const usage = normalizeCursorUsage(value.usage)
|
|
247
|
+
if (usage) {
|
|
248
|
+
events.push({
|
|
249
|
+
type: "transcript",
|
|
250
|
+
entry: timestamped({ kind: "context_window_updated", usage }),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
const isError = Boolean(value.is_error)
|
|
254
|
+
events.push({
|
|
255
|
+
type: "transcript",
|
|
256
|
+
entry: timestamped({
|
|
257
|
+
kind: "result",
|
|
258
|
+
subtype: isError ? "error" : "success",
|
|
259
|
+
isError,
|
|
260
|
+
durationMs: asNumber(value.duration_ms) ?? 0,
|
|
261
|
+
result: asString(value.result) ?? "",
|
|
262
|
+
debugRaw,
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
return events
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// "user" (prompt echo), "thinking" (reasoning), and unknown types are dropped —
|
|
269
|
+
// Kanna already records the user prompt and has no reasoning transcript kind.
|
|
270
|
+
default:
|
|
271
|
+
return []
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export class CursorCliManager {
|
|
276
|
+
private readonly spawnProcess: SpawnCursorAgent
|
|
277
|
+
|
|
278
|
+
constructor(args: { spawnProcess?: SpawnCursorAgent } = {}) {
|
|
279
|
+
this.spawnProcess =
|
|
280
|
+
args.spawnProcess ??
|
|
281
|
+
(({ cwd, argv }) =>
|
|
282
|
+
spawn("cursor-agent", argv, {
|
|
283
|
+
cwd,
|
|
284
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
285
|
+
env: process.env,
|
|
286
|
+
}) as unknown as CursorChildProcess)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async startTurn(args: StartCursorTurnArgs): Promise<HarnessTurn> {
|
|
290
|
+
const argv = [
|
|
291
|
+
"-p",
|
|
292
|
+
"--output-format",
|
|
293
|
+
"stream-json",
|
|
294
|
+
"--force",
|
|
295
|
+
"--model",
|
|
296
|
+
args.model,
|
|
297
|
+
]
|
|
298
|
+
if (args.sessionToken) {
|
|
299
|
+
argv.push("--resume", args.sessionToken)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const child = this.spawnProcess({ cwd: args.cwd, argv })
|
|
303
|
+
const queue = new AsyncQueue<HarnessEvent>()
|
|
304
|
+
|
|
305
|
+
let sawResult = false
|
|
306
|
+
let finished = false
|
|
307
|
+
let stderr = ""
|
|
308
|
+
|
|
309
|
+
const finalize = (code: number | null) => {
|
|
310
|
+
if (finished) return
|
|
311
|
+
finished = true
|
|
312
|
+
if (!sawResult) {
|
|
313
|
+
const detail = stderr.trim() || `cursor-agent exited with code ${code ?? "unknown"}`
|
|
314
|
+
queue.push({
|
|
315
|
+
type: "transcript",
|
|
316
|
+
entry: timestamped({
|
|
317
|
+
kind: "result",
|
|
318
|
+
subtype: "error",
|
|
319
|
+
isError: true,
|
|
320
|
+
durationMs: 0,
|
|
321
|
+
result: detail,
|
|
322
|
+
}),
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
queue.finish()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (child.stdout) {
|
|
329
|
+
const rl = createInterface({ input: child.stdout })
|
|
330
|
+
rl.on("line", (line) => {
|
|
331
|
+
for (const event of parseCursorLine(line, args.model)) {
|
|
332
|
+
if (event.type === "transcript" && event.entry?.kind === "result") {
|
|
333
|
+
sawResult = true
|
|
334
|
+
}
|
|
335
|
+
queue.push(event)
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (child.stderr) {
|
|
341
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
342
|
+
stderr += chunk.toString()
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
child.once("error", (err) => {
|
|
347
|
+
stderr += `\n${err.message}`
|
|
348
|
+
finalize(null)
|
|
349
|
+
})
|
|
350
|
+
child.once("close", (code) => finalize(code))
|
|
351
|
+
|
|
352
|
+
// Send the prompt over stdin and close it so the agent starts.
|
|
353
|
+
if (child.stdin) {
|
|
354
|
+
child.stdin.end(args.content)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
provider: "cursor",
|
|
359
|
+
stream: queue,
|
|
360
|
+
interrupt: async () => {
|
|
361
|
+
try {
|
|
362
|
+
child.kill("SIGINT")
|
|
363
|
+
} catch {
|
|
364
|
+
// process already gone
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
close: () => {
|
|
368
|
+
try {
|
|
369
|
+
child.kill()
|
|
370
|
+
} catch {
|
|
371
|
+
// process already gone
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
package/src/server/paths.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
1
2
|
import { mkdir, stat } from "node:fs/promises"
|
|
2
3
|
import { homedir } from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
@@ -26,6 +27,69 @@ export async function ensureProjectDirectory(localPath: string) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await stat(p)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick a clone destination that doesn't already exist.
|
|
41
|
+
* Tries `localPath` first, then falls back to `fallbackPath` if provided.
|
|
42
|
+
* Returns the resolved absolute path that was chosen.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveClonePath(localPath: string, fallbackPath?: string): Promise<string> {
|
|
45
|
+
const primary = resolveLocalPath(localPath)
|
|
46
|
+
if (!(await pathExists(primary))) {
|
|
47
|
+
return primary
|
|
48
|
+
}
|
|
49
|
+
if (fallbackPath) {
|
|
50
|
+
const secondary = resolveLocalPath(fallbackPath)
|
|
51
|
+
if (!(await pathExists(secondary))) {
|
|
52
|
+
return secondary
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Destination path '${primary}' already exists`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clone a git repository into the given local path.
|
|
60
|
+
* The parent directory is created if it doesn't exist.
|
|
61
|
+
* Rejects if `git clone` exits with a non-zero code.
|
|
62
|
+
*/
|
|
63
|
+
export async function cloneRepository(cloneUrl: string, resolvedPath: string): Promise<void> {
|
|
64
|
+
const parentDir = path.dirname(resolvedPath)
|
|
65
|
+
|
|
66
|
+
await mkdir(parentDir, { recursive: true })
|
|
67
|
+
|
|
68
|
+
return new Promise<void>((resolve, reject) => {
|
|
69
|
+
const child = spawn("git", ["clone", cloneUrl, resolvedPath], {
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
let stderr = ""
|
|
74
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
75
|
+
stderr += chunk.toString()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
child.on("error", (err) => {
|
|
79
|
+
reject(new Error(`Failed to start git clone: ${err.message}`))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
child.on("close", (code) => {
|
|
83
|
+
if (code === 0) {
|
|
84
|
+
resolve()
|
|
85
|
+
} else {
|
|
86
|
+
const message = stderr.trim() || `git clone exited with code ${code}`
|
|
87
|
+
reject(new Error(message))
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
29
93
|
export function getProjectUploadDir(localPath: string) {
|
|
30
94
|
return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
|
|
31
95
|
}
|