kanna-code 0.1.4 → 0.2.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-C-sGbl7X.js +409 -0
- package/dist/client/assets/index-gld9RxCU.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +5 -1
- package/src/server/agent.test.ts +415 -0
- package/src/server/agent.ts +483 -194
- package/src/server/codex-app-server-protocol.ts +440 -0
- package/src/server/codex-app-server.test.ts +1303 -0
- package/src/server/codex-app-server.ts +1277 -0
- package/src/server/event-store.ts +81 -34
- package/src/server/events.ts +25 -17
- package/src/server/harness-types.ts +19 -0
- package/src/server/provider-catalog.test.ts +34 -0
- package/src/server/provider-catalog.ts +77 -0
- package/src/server/read-models.test.ts +63 -0
- package/src/server/read-models.ts +5 -1
- package/src/shared/protocol.ts +12 -2
- package/src/shared/tools.test.ts +88 -0
- package/src/shared/tools.ts +233 -0
- package/src/shared/types.ts +404 -5
- package/dist/client/assets/index-BRiM6Nxc.css +0 -1
- package/dist/client/assets/index-CjRaPaQM.js +0 -418
|
@@ -0,0 +1,1277 @@
|
|
|
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 { AskUserQuestionItem, CodexReasoningEffort, ServiceTier, TodoItem, TranscriptEntry } from "../shared/types"
|
|
6
|
+
import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
|
|
7
|
+
import {
|
|
8
|
+
type CollabAgentToolCallItem,
|
|
9
|
+
type ContextCompactedNotification,
|
|
10
|
+
type CodexRequestId,
|
|
11
|
+
type CommandExecutionApprovalDecision,
|
|
12
|
+
type CommandExecutionRequestApprovalParams,
|
|
13
|
+
type CommandExecutionRequestApprovalResponse,
|
|
14
|
+
type DynamicToolCallOutputContentItem,
|
|
15
|
+
type DynamicToolCallResponse,
|
|
16
|
+
type FileChangeApprovalDecision,
|
|
17
|
+
type FileChangeRequestApprovalParams,
|
|
18
|
+
type FileChangeRequestApprovalResponse,
|
|
19
|
+
type InitializeParams,
|
|
20
|
+
type ItemCompletedNotification,
|
|
21
|
+
type ItemStartedNotification,
|
|
22
|
+
type JsonRpcResponse,
|
|
23
|
+
type McpToolCallItem,
|
|
24
|
+
type PlanDeltaNotification,
|
|
25
|
+
type ServerNotification,
|
|
26
|
+
type ServerRequest,
|
|
27
|
+
type ThreadItem,
|
|
28
|
+
type ThreadResumeParams,
|
|
29
|
+
type ThreadResumeResponse,
|
|
30
|
+
type ThreadStartParams,
|
|
31
|
+
type ThreadStartResponse,
|
|
32
|
+
type ToolRequestUserInputParams,
|
|
33
|
+
type ToolRequestUserInputResponse,
|
|
34
|
+
type TurnPlanStep,
|
|
35
|
+
type TurnPlanUpdatedNotification,
|
|
36
|
+
type TurnCompletedNotification,
|
|
37
|
+
type TurnInterruptParams,
|
|
38
|
+
type TurnStartParams,
|
|
39
|
+
type TurnStartResponse,
|
|
40
|
+
isJsonRpcResponse,
|
|
41
|
+
isServerNotification,
|
|
42
|
+
isServerRequest,
|
|
43
|
+
} from "./codex-app-server-protocol"
|
|
44
|
+
|
|
45
|
+
interface CodexAppServerProcess {
|
|
46
|
+
stdin: Writable
|
|
47
|
+
stdout: Readable
|
|
48
|
+
stderr: Readable
|
|
49
|
+
killed?: boolean
|
|
50
|
+
kill(signal?: NodeJS.Signals | number): void
|
|
51
|
+
on(event: "close", listener: (code: number | null) => void): this
|
|
52
|
+
on(event: "error", listener: (error: Error) => void): this
|
|
53
|
+
once(event: "close", listener: (code: number | null) => void): this
|
|
54
|
+
once(event: "error", listener: (error: Error) => void): this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type SpawnCodexAppServer = (cwd: string) => CodexAppServerProcess
|
|
58
|
+
|
|
59
|
+
interface PendingRequest<TResult> {
|
|
60
|
+
method: string
|
|
61
|
+
resolve: (value: TResult) => void
|
|
62
|
+
reject: (error: Error) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PendingTurn {
|
|
66
|
+
turnId: string | null
|
|
67
|
+
model: string
|
|
68
|
+
planMode: boolean
|
|
69
|
+
queue: AsyncQueue<HarnessEvent>
|
|
70
|
+
startedToolIds: Set<string>
|
|
71
|
+
handledDynamicToolIds: Set<string>
|
|
72
|
+
latestPlanExplanation: string | null
|
|
73
|
+
latestPlanSteps: TurnPlanStep[]
|
|
74
|
+
latestPlanText: string | null
|
|
75
|
+
planTextByItemId: Map<string, string>
|
|
76
|
+
todoSequence: number
|
|
77
|
+
pendingWebSearchResultToolId: string | null
|
|
78
|
+
resolved: boolean
|
|
79
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
80
|
+
onApprovalRequest?: (
|
|
81
|
+
request:
|
|
82
|
+
| {
|
|
83
|
+
requestId: CodexRequestId
|
|
84
|
+
kind: "command_execution"
|
|
85
|
+
params: CommandExecutionRequestApprovalParams
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
requestId: CodexRequestId
|
|
89
|
+
kind: "file_change"
|
|
90
|
+
params: FileChangeRequestApprovalParams
|
|
91
|
+
}
|
|
92
|
+
) => Promise<CommandExecutionApprovalDecision | FileChangeApprovalDecision>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface SessionContext {
|
|
96
|
+
chatId: string
|
|
97
|
+
cwd: string
|
|
98
|
+
child: CodexAppServerProcess
|
|
99
|
+
pendingRequests: Map<CodexRequestId, PendingRequest<unknown>>
|
|
100
|
+
pendingTurn: PendingTurn | null
|
|
101
|
+
sessionToken: string | null
|
|
102
|
+
stderrLines: string[]
|
|
103
|
+
closed: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface StartCodexSessionArgs {
|
|
107
|
+
chatId: string
|
|
108
|
+
cwd: string
|
|
109
|
+
model: string
|
|
110
|
+
serviceTier?: ServiceTier
|
|
111
|
+
sessionToken: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface StartCodexTurnArgs {
|
|
115
|
+
chatId: string
|
|
116
|
+
model: string
|
|
117
|
+
effort?: CodexReasoningEffort
|
|
118
|
+
serviceTier?: ServiceTier
|
|
119
|
+
content: string
|
|
120
|
+
planMode: boolean
|
|
121
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
122
|
+
onApprovalRequest?: PendingTurn["onApprovalRequest"]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
126
|
+
entry: T,
|
|
127
|
+
createdAt = Date.now()
|
|
128
|
+
): TranscriptEntry {
|
|
129
|
+
return {
|
|
130
|
+
_id: randomUUID(),
|
|
131
|
+
createdAt,
|
|
132
|
+
...entry,
|
|
133
|
+
} as TranscriptEntry
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function codexSystemInitEntry(model: string): TranscriptEntry {
|
|
137
|
+
return timestamped({
|
|
138
|
+
kind: "system_init",
|
|
139
|
+
provider: "codex",
|
|
140
|
+
model,
|
|
141
|
+
tools: ["Bash", "Write", "Edit", "WebSearch", "TodoWrite", "AskUserQuestion", "ExitPlanMode"],
|
|
142
|
+
agents: ["spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent"],
|
|
143
|
+
slashCommands: [],
|
|
144
|
+
mcpServers: [],
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function errorMessage(value: unknown): string {
|
|
149
|
+
if (value instanceof Error) return value.message
|
|
150
|
+
return String(value)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseJsonLine(line: string): unknown | null {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(line)
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isRecoverableResumeError(error: unknown): boolean {
|
|
162
|
+
const message = errorMessage(error).toLowerCase()
|
|
163
|
+
if (!message.includes("thread/resume")) return false
|
|
164
|
+
return ["not found", "missing thread", "no such thread", "unknown thread", "does not exist"].some((snippet) =>
|
|
165
|
+
message.includes(snippet)
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQuestionItem[] {
|
|
170
|
+
return params.questions.map((question) => ({
|
|
171
|
+
id: question.id,
|
|
172
|
+
question: question.question,
|
|
173
|
+
header: question.header || undefined,
|
|
174
|
+
options: question.options?.map((option) => ({
|
|
175
|
+
label: option.label,
|
|
176
|
+
description: option.description ?? undefined,
|
|
177
|
+
})),
|
|
178
|
+
multiSelect: false,
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toToolRequestUserInputResponse(raw: unknown, questions: ToolRequestUserInputParams["questions"]): ToolRequestUserInputResponse {
|
|
183
|
+
const record = raw && typeof raw === "object" ? raw as Record<string, unknown> : {}
|
|
184
|
+
const answersValue = record.answers
|
|
185
|
+
const value = answersValue && typeof answersValue === "object" && !Array.isArray(answersValue)
|
|
186
|
+
? answersValue as Record<string, unknown>
|
|
187
|
+
: record
|
|
188
|
+
const answers = Object.fromEntries(
|
|
189
|
+
questions.map((question) => {
|
|
190
|
+
const rawAnswer = value[question.id] ?? value[question.question]
|
|
191
|
+
if (Array.isArray(rawAnswer)) {
|
|
192
|
+
return [question.id, { answers: rawAnswer.map((entry) => String(entry)) }]
|
|
193
|
+
}
|
|
194
|
+
if (typeof rawAnswer === "string") {
|
|
195
|
+
return [question.id, { answers: [rawAnswer] }]
|
|
196
|
+
}
|
|
197
|
+
if (rawAnswer && typeof rawAnswer === "object" && Array.isArray((rawAnswer as { answers?: unknown }).answers)) {
|
|
198
|
+
return [question.id, { answers: ((rawAnswer as { answers: unknown[] }).answers).map((entry) => String(entry)) }]
|
|
199
|
+
}
|
|
200
|
+
return [question.id, { answers: [] }]
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
return { answers }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function contentFromMcpResult(item: McpToolCallItem): unknown {
|
|
207
|
+
if (item.error?.message) {
|
|
208
|
+
return { error: item.error.message }
|
|
209
|
+
}
|
|
210
|
+
return item.result?.structuredContent ?? item.result?.content ?? null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
214
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
215
|
+
return value as Record<string, unknown>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function todoStatus(status: TurnPlanStep["status"]): TodoItem["status"] {
|
|
219
|
+
if (status === "completed") return "completed"
|
|
220
|
+
if (status === "inProgress") return "in_progress"
|
|
221
|
+
return "pending"
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function planStepsToTodos(steps: TurnPlanStep[]): TodoItem[] {
|
|
225
|
+
return steps.map((step) => ({
|
|
226
|
+
content: step.step,
|
|
227
|
+
status: todoStatus(step.status),
|
|
228
|
+
activeForm: step.step,
|
|
229
|
+
}))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderPlanMarkdownFromSteps(steps: TurnPlanStep[]): string {
|
|
233
|
+
return steps.map((step) => {
|
|
234
|
+
const checkbox = step.status === "completed" ? "[x]" : "[ ]"
|
|
235
|
+
return `- ${checkbox} ${step.step}`
|
|
236
|
+
}).join("\n")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function dynamicContentToText(contentItems: DynamicToolCallOutputContentItem[] | null | undefined): string {
|
|
240
|
+
if (!contentItems?.length) return ""
|
|
241
|
+
return contentItems
|
|
242
|
+
.map((item) => item.type === "inputText" ? item.text ?? "" : item.imageUrl ?? "")
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.join("\n")
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function dynamicToolPayload(value: Record<string, unknown> | unknown[] | string | number | boolean | null | undefined): Record<string, unknown> {
|
|
248
|
+
const record = asRecord(value)
|
|
249
|
+
if (record) return record
|
|
250
|
+
return { value }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function webSearchQuery(item: Extract<ThreadItem, { type: "webSearch" }>): string {
|
|
254
|
+
return item.query || item.action?.query || item.action?.queries?.find((query) => typeof query === "string") || ""
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function genericDynamicToolCall(toolId: string, toolName: string, input: Record<string, unknown>): TranscriptEntry {
|
|
258
|
+
return timestamped({
|
|
259
|
+
kind: "tool_call",
|
|
260
|
+
tool: {
|
|
261
|
+
kind: "tool",
|
|
262
|
+
toolKind: "unknown_tool",
|
|
263
|
+
toolName,
|
|
264
|
+
toolId,
|
|
265
|
+
input: {
|
|
266
|
+
payload: input,
|
|
267
|
+
},
|
|
268
|
+
rawInput: input,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function collabToolCall(item: CollabAgentToolCallItem): TranscriptEntry {
|
|
274
|
+
return timestamped({
|
|
275
|
+
kind: "tool_call",
|
|
276
|
+
tool: {
|
|
277
|
+
kind: "tool",
|
|
278
|
+
toolKind: "subagent_task",
|
|
279
|
+
toolName: "Task",
|
|
280
|
+
toolId: item.id,
|
|
281
|
+
input: {
|
|
282
|
+
subagentType: item.tool,
|
|
283
|
+
},
|
|
284
|
+
rawInput: item as unknown as Record<string, unknown>,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function todoToolCall(toolId: string, steps: TurnPlanStep[]): TranscriptEntry {
|
|
290
|
+
return timestamped({
|
|
291
|
+
kind: "tool_call",
|
|
292
|
+
tool: {
|
|
293
|
+
kind: "tool",
|
|
294
|
+
toolKind: "todo_write",
|
|
295
|
+
toolName: "TodoWrite",
|
|
296
|
+
toolId,
|
|
297
|
+
input: {
|
|
298
|
+
todos: planStepsToTodos(steps),
|
|
299
|
+
},
|
|
300
|
+
rawInput: {
|
|
301
|
+
plan: steps,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fileChangeKind(
|
|
308
|
+
kind: "add" | "delete" | "update" | { type: "add" | "delete" | "update"; move_path?: string | null }
|
|
309
|
+
): { type: "add" | "delete" | "update"; movePath?: string | null } {
|
|
310
|
+
if (typeof kind === "string") {
|
|
311
|
+
return { type: kind }
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
type: kind.type,
|
|
315
|
+
movePath: kind.move_path ?? null,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fileChangeToolId(itemId: string, index: number, totalChanges: number): string {
|
|
320
|
+
if (totalChanges === 1) {
|
|
321
|
+
return itemId
|
|
322
|
+
}
|
|
323
|
+
return `${itemId}:change:${index}`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function fileChangePayload(
|
|
327
|
+
item: Extract<ThreadItem, { type: "fileChange" }>,
|
|
328
|
+
change: Extract<ThreadItem, { type: "fileChange" }>["changes"][number]
|
|
329
|
+
): Record<string, unknown> {
|
|
330
|
+
return {
|
|
331
|
+
...item,
|
|
332
|
+
changes: [change],
|
|
333
|
+
} as unknown as Record<string, unknown>
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseUnifiedDiff(diff: string): { oldString: string; newString: string } {
|
|
337
|
+
const oldLines: string[] = []
|
|
338
|
+
const newLines: string[] = []
|
|
339
|
+
|
|
340
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
341
|
+
if (!line) continue
|
|
342
|
+
if (line.startsWith("@@") || line.startsWith("---") || line.startsWith("+++")) continue
|
|
343
|
+
if (line === "\") continue
|
|
344
|
+
|
|
345
|
+
const prefix = line[0]
|
|
346
|
+
const content = line.slice(1)
|
|
347
|
+
|
|
348
|
+
if (prefix === " ") {
|
|
349
|
+
oldLines.push(content)
|
|
350
|
+
newLines.push(content)
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
if (prefix === "-") {
|
|
354
|
+
oldLines.push(content)
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (prefix === "+") {
|
|
358
|
+
newLines.push(content)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
oldString: oldLines.join("\n"),
|
|
364
|
+
newString: newLines.join("\n"),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>): TranscriptEntry[] {
|
|
369
|
+
return item.changes.map((change, index) => {
|
|
370
|
+
const payload = fileChangePayload(item, change)
|
|
371
|
+
const toolId = fileChangeToolId(item.id, index, item.changes.length)
|
|
372
|
+
const normalizedKind = fileChangeKind(change.kind)
|
|
373
|
+
|
|
374
|
+
if (normalizedKind.movePath) {
|
|
375
|
+
return timestamped({
|
|
376
|
+
kind: "tool_call",
|
|
377
|
+
tool: {
|
|
378
|
+
kind: "tool",
|
|
379
|
+
toolKind: "unknown_tool",
|
|
380
|
+
toolName: "FileChange",
|
|
381
|
+
toolId,
|
|
382
|
+
input: {
|
|
383
|
+
payload,
|
|
384
|
+
},
|
|
385
|
+
rawInput: payload,
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (typeof change.diff === "string") {
|
|
391
|
+
const { oldString, newString } = parseUnifiedDiff(change.diff)
|
|
392
|
+
|
|
393
|
+
if (normalizedKind.type === "add") {
|
|
394
|
+
return timestamped({
|
|
395
|
+
kind: "tool_call",
|
|
396
|
+
tool: {
|
|
397
|
+
kind: "tool",
|
|
398
|
+
toolKind: "write_file",
|
|
399
|
+
toolName: "Write",
|
|
400
|
+
toolId,
|
|
401
|
+
input: {
|
|
402
|
+
filePath: change.path,
|
|
403
|
+
content: newString,
|
|
404
|
+
},
|
|
405
|
+
rawInput: payload,
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (normalizedKind.type === "update") {
|
|
411
|
+
return timestamped({
|
|
412
|
+
kind: "tool_call",
|
|
413
|
+
tool: {
|
|
414
|
+
kind: "tool",
|
|
415
|
+
toolKind: "edit_file",
|
|
416
|
+
toolName: "Edit",
|
|
417
|
+
toolId,
|
|
418
|
+
input: {
|
|
419
|
+
filePath: change.path,
|
|
420
|
+
oldString,
|
|
421
|
+
newString,
|
|
422
|
+
},
|
|
423
|
+
rawInput: payload,
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return timestamped({
|
|
430
|
+
kind: "tool_call",
|
|
431
|
+
tool: {
|
|
432
|
+
kind: "tool",
|
|
433
|
+
toolKind: "unknown_tool",
|
|
434
|
+
toolName: "FileChange",
|
|
435
|
+
toolId,
|
|
436
|
+
input: {
|
|
437
|
+
payload,
|
|
438
|
+
},
|
|
439
|
+
rawInput: payload,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function fileChangeToToolResults(item: Extract<ThreadItem, { type: "fileChange" }>): TranscriptEntry[] {
|
|
446
|
+
return item.changes.map((change, index) => timestamped({
|
|
447
|
+
kind: "tool_result",
|
|
448
|
+
toolId: fileChangeToolId(item.id, index, item.changes.length),
|
|
449
|
+
content: fileChangePayload(item, change),
|
|
450
|
+
isError: item.status === "failed" || item.status === "declined",
|
|
451
|
+
}))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function itemToToolCalls(item: ThreadItem): TranscriptEntry[] {
|
|
455
|
+
switch (item.type) {
|
|
456
|
+
case "dynamicToolCall":
|
|
457
|
+
return [genericDynamicToolCall(item.id, item.tool, dynamicToolPayload(item.arguments))]
|
|
458
|
+
case "collabAgentToolCall":
|
|
459
|
+
return [collabToolCall(item)]
|
|
460
|
+
case "commandExecution":
|
|
461
|
+
return [timestamped({
|
|
462
|
+
kind: "tool_call",
|
|
463
|
+
tool: {
|
|
464
|
+
kind: "tool",
|
|
465
|
+
toolKind: "bash",
|
|
466
|
+
toolName: "Bash",
|
|
467
|
+
toolId: item.id,
|
|
468
|
+
input: {
|
|
469
|
+
command: item.command,
|
|
470
|
+
},
|
|
471
|
+
rawInput: item,
|
|
472
|
+
},
|
|
473
|
+
})]
|
|
474
|
+
case "webSearch":
|
|
475
|
+
return [timestamped({
|
|
476
|
+
kind: "tool_call",
|
|
477
|
+
tool: {
|
|
478
|
+
kind: "tool",
|
|
479
|
+
toolKind: "web_search",
|
|
480
|
+
toolName: "WebSearch",
|
|
481
|
+
toolId: item.id,
|
|
482
|
+
input: {
|
|
483
|
+
query: webSearchQuery(item),
|
|
484
|
+
},
|
|
485
|
+
rawInput: item,
|
|
486
|
+
},
|
|
487
|
+
})]
|
|
488
|
+
case "mcpToolCall":
|
|
489
|
+
return [timestamped({
|
|
490
|
+
kind: "tool_call",
|
|
491
|
+
tool: {
|
|
492
|
+
kind: "tool",
|
|
493
|
+
toolKind: "mcp_generic",
|
|
494
|
+
toolName: `mcp__${item.server}__${item.tool}`,
|
|
495
|
+
toolId: item.id,
|
|
496
|
+
input: {
|
|
497
|
+
server: item.server,
|
|
498
|
+
tool: item.tool,
|
|
499
|
+
payload: item.arguments ?? {},
|
|
500
|
+
},
|
|
501
|
+
rawInput: item.arguments ?? {},
|
|
502
|
+
},
|
|
503
|
+
})]
|
|
504
|
+
case "fileChange":
|
|
505
|
+
return fileChangeToToolCalls(item)
|
|
506
|
+
case "plan":
|
|
507
|
+
return []
|
|
508
|
+
case "error":
|
|
509
|
+
return [timestamped({
|
|
510
|
+
kind: "tool_call",
|
|
511
|
+
tool: {
|
|
512
|
+
kind: "tool",
|
|
513
|
+
toolKind: "unknown_tool",
|
|
514
|
+
toolName: "Error",
|
|
515
|
+
toolId: item.id,
|
|
516
|
+
input: {
|
|
517
|
+
payload: item as unknown as Record<string, unknown>,
|
|
518
|
+
},
|
|
519
|
+
rawInput: item as unknown as Record<string, unknown>,
|
|
520
|
+
},
|
|
521
|
+
})]
|
|
522
|
+
default:
|
|
523
|
+
return []
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function itemToToolResults(item: ThreadItem): TranscriptEntry[] {
|
|
528
|
+
switch (item.type) {
|
|
529
|
+
case "dynamicToolCall":
|
|
530
|
+
return [timestamped({
|
|
531
|
+
kind: "tool_result",
|
|
532
|
+
toolId: item.id,
|
|
533
|
+
content: dynamicContentToText(item.contentItems) || item,
|
|
534
|
+
isError: item.status === "failed" || item.success === false,
|
|
535
|
+
})]
|
|
536
|
+
case "collabAgentToolCall":
|
|
537
|
+
return [timestamped({
|
|
538
|
+
kind: "tool_result",
|
|
539
|
+
toolId: item.id,
|
|
540
|
+
content: item,
|
|
541
|
+
isError: item.status === "failed",
|
|
542
|
+
})]
|
|
543
|
+
case "commandExecution":
|
|
544
|
+
return [timestamped({
|
|
545
|
+
kind: "tool_result",
|
|
546
|
+
toolId: item.id,
|
|
547
|
+
content: item.aggregatedOutput ?? item,
|
|
548
|
+
isError: (typeof item.exitCode === "number" && item.exitCode !== 0) || item.status === "failed" || item.status === "declined",
|
|
549
|
+
})]
|
|
550
|
+
case "webSearch":
|
|
551
|
+
return [timestamped({
|
|
552
|
+
kind: "tool_result",
|
|
553
|
+
toolId: item.id,
|
|
554
|
+
content: item,
|
|
555
|
+
})]
|
|
556
|
+
case "mcpToolCall":
|
|
557
|
+
return [timestamped({
|
|
558
|
+
kind: "tool_result",
|
|
559
|
+
toolId: item.id,
|
|
560
|
+
content: contentFromMcpResult(item),
|
|
561
|
+
isError: item.status === "failed",
|
|
562
|
+
})]
|
|
563
|
+
case "fileChange":
|
|
564
|
+
return fileChangeToToolResults(item)
|
|
565
|
+
case "plan":
|
|
566
|
+
return []
|
|
567
|
+
case "error":
|
|
568
|
+
return [timestamped({
|
|
569
|
+
kind: "tool_result",
|
|
570
|
+
toolId: item.id,
|
|
571
|
+
content: item.message,
|
|
572
|
+
isError: true,
|
|
573
|
+
})]
|
|
574
|
+
default:
|
|
575
|
+
return []
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
580
|
+
private values: T[] = []
|
|
581
|
+
private resolvers: Array<(value: IteratorResult<T>) => void> = []
|
|
582
|
+
private done = false
|
|
583
|
+
|
|
584
|
+
push(value: T) {
|
|
585
|
+
if (this.done) return
|
|
586
|
+
const resolver = this.resolvers.shift()
|
|
587
|
+
if (resolver) {
|
|
588
|
+
resolver({ value, done: false })
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
this.values.push(value)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
finish() {
|
|
595
|
+
if (this.done) return
|
|
596
|
+
this.done = true
|
|
597
|
+
while (this.resolvers.length > 0) {
|
|
598
|
+
const resolver = this.resolvers.shift()
|
|
599
|
+
resolver?.({ value: undefined as T, done: true })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
604
|
+
return {
|
|
605
|
+
next: () => {
|
|
606
|
+
if (this.values.length > 0) {
|
|
607
|
+
return Promise.resolve({ value: this.values.shift() as T, done: false })
|
|
608
|
+
}
|
|
609
|
+
if (this.done) {
|
|
610
|
+
return Promise.resolve({ value: undefined as T, done: true })
|
|
611
|
+
}
|
|
612
|
+
return new Promise<IteratorResult<T>>((resolve) => {
|
|
613
|
+
this.resolvers.push(resolve)
|
|
614
|
+
})
|
|
615
|
+
},
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export class CodexAppServerManager {
|
|
621
|
+
private readonly sessions = new Map<string, SessionContext>()
|
|
622
|
+
private readonly spawnProcess: SpawnCodexAppServer
|
|
623
|
+
|
|
624
|
+
constructor(args: { spawnProcess?: SpawnCodexAppServer } = {}) {
|
|
625
|
+
this.spawnProcess = args.spawnProcess ?? ((cwd) =>
|
|
626
|
+
spawn("codex", ["app-server"], {
|
|
627
|
+
cwd,
|
|
628
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
629
|
+
env: process.env,
|
|
630
|
+
}) as unknown as CodexAppServerProcess)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async startSession(args: StartCodexSessionArgs) {
|
|
634
|
+
const existing = this.sessions.get(args.chatId)
|
|
635
|
+
if (existing && !existing.closed && existing.cwd === args.cwd) {
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (existing) {
|
|
640
|
+
this.stopSession(args.chatId)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const child = this.spawnProcess(args.cwd)
|
|
644
|
+
const context: SessionContext = {
|
|
645
|
+
chatId: args.chatId,
|
|
646
|
+
cwd: args.cwd,
|
|
647
|
+
child,
|
|
648
|
+
pendingRequests: new Map(),
|
|
649
|
+
pendingTurn: null,
|
|
650
|
+
sessionToken: null,
|
|
651
|
+
stderrLines: [],
|
|
652
|
+
closed: false,
|
|
653
|
+
}
|
|
654
|
+
this.sessions.set(args.chatId, context)
|
|
655
|
+
this.attachListeners(context)
|
|
656
|
+
|
|
657
|
+
await this.sendRequest(context, "initialize", {
|
|
658
|
+
clientInfo: {
|
|
659
|
+
name: "kanna_desktop",
|
|
660
|
+
title: "Kanna",
|
|
661
|
+
version: "0.1.0",
|
|
662
|
+
},
|
|
663
|
+
capabilities: {
|
|
664
|
+
experimentalApi: true,
|
|
665
|
+
},
|
|
666
|
+
} satisfies InitializeParams)
|
|
667
|
+
this.writeMessage(context, {
|
|
668
|
+
method: "initialized",
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
const threadParams = {
|
|
672
|
+
model: args.model,
|
|
673
|
+
cwd: args.cwd,
|
|
674
|
+
serviceTier: args.serviceTier,
|
|
675
|
+
approvalPolicy: "never",
|
|
676
|
+
sandbox: "danger-full-access",
|
|
677
|
+
experimentalRawEvents: false,
|
|
678
|
+
persistExtendedHistory: false,
|
|
679
|
+
} satisfies ThreadStartParams
|
|
680
|
+
|
|
681
|
+
let response: ThreadStartResponse | ThreadResumeResponse
|
|
682
|
+
if (args.sessionToken) {
|
|
683
|
+
try {
|
|
684
|
+
response = await this.sendRequest<ThreadResumeResponse>(context, "thread/resume", {
|
|
685
|
+
threadId: args.sessionToken,
|
|
686
|
+
model: args.model,
|
|
687
|
+
cwd: args.cwd,
|
|
688
|
+
serviceTier: args.serviceTier,
|
|
689
|
+
approvalPolicy: "never",
|
|
690
|
+
sandbox: "danger-full-access",
|
|
691
|
+
persistExtendedHistory: false,
|
|
692
|
+
} satisfies ThreadResumeParams)
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!isRecoverableResumeError(error)) {
|
|
695
|
+
this.stopSession(args.chatId)
|
|
696
|
+
throw error
|
|
697
|
+
}
|
|
698
|
+
response = await this.sendRequest<ThreadStartResponse>(context, "thread/start", threadParams)
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
response = await this.sendRequest<ThreadStartResponse>(context, "thread/start", threadParams)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
context.sessionToken = response.thread.id
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async startTurn(args: StartCodexTurnArgs): Promise<HarnessTurn> {
|
|
708
|
+
const context = this.requireSession(args.chatId)
|
|
709
|
+
if (context.pendingTurn) {
|
|
710
|
+
throw new Error("Codex turn is already running")
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const queue = new AsyncQueue<HarnessEvent>()
|
|
714
|
+
if (context.sessionToken) {
|
|
715
|
+
queue.push({ type: "session_token", sessionToken: context.sessionToken })
|
|
716
|
+
}
|
|
717
|
+
queue.push({ type: "transcript", entry: codexSystemInitEntry(args.model) })
|
|
718
|
+
|
|
719
|
+
const pendingTurn: PendingTurn = {
|
|
720
|
+
turnId: null,
|
|
721
|
+
model: args.model,
|
|
722
|
+
planMode: args.planMode,
|
|
723
|
+
queue,
|
|
724
|
+
startedToolIds: new Set(),
|
|
725
|
+
handledDynamicToolIds: new Set(),
|
|
726
|
+
latestPlanExplanation: null,
|
|
727
|
+
latestPlanSteps: [],
|
|
728
|
+
latestPlanText: null,
|
|
729
|
+
planTextByItemId: new Map(),
|
|
730
|
+
todoSequence: 0,
|
|
731
|
+
pendingWebSearchResultToolId: null,
|
|
732
|
+
resolved: false,
|
|
733
|
+
onToolRequest: args.onToolRequest,
|
|
734
|
+
onApprovalRequest: args.onApprovalRequest,
|
|
735
|
+
}
|
|
736
|
+
context.pendingTurn = pendingTurn
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const response = await this.sendRequest<TurnStartResponse>(context, "turn/start", {
|
|
740
|
+
threadId: context.sessionToken ?? "",
|
|
741
|
+
input: [
|
|
742
|
+
{
|
|
743
|
+
type: "text",
|
|
744
|
+
text: args.content,
|
|
745
|
+
text_elements: [],
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
approvalPolicy: "never",
|
|
749
|
+
model: args.model,
|
|
750
|
+
effort: args.effort,
|
|
751
|
+
serviceTier: args.serviceTier,
|
|
752
|
+
collaborationMode: {
|
|
753
|
+
mode: args.planMode ? "plan" : "default",
|
|
754
|
+
settings: {
|
|
755
|
+
model: args.model,
|
|
756
|
+
reasoning_effort: null,
|
|
757
|
+
developer_instructions: null,
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
} satisfies TurnStartParams)
|
|
761
|
+
if (context.pendingTurn) {
|
|
762
|
+
context.pendingTurn.turnId = response.turn.id
|
|
763
|
+
} else {
|
|
764
|
+
pendingTurn.turnId = response.turn.id
|
|
765
|
+
}
|
|
766
|
+
} catch (error) {
|
|
767
|
+
context.pendingTurn = null
|
|
768
|
+
queue.finish()
|
|
769
|
+
throw error
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
provider: "codex",
|
|
774
|
+
stream: queue,
|
|
775
|
+
interrupt: async () => {
|
|
776
|
+
const pendingTurn = context.pendingTurn
|
|
777
|
+
if (!pendingTurn?.turnId || !context.sessionToken) return
|
|
778
|
+
await this.sendRequest(context, "turn/interrupt", {
|
|
779
|
+
threadId: context.sessionToken,
|
|
780
|
+
turnId: pendingTurn.turnId,
|
|
781
|
+
} satisfies TurnInterruptParams)
|
|
782
|
+
},
|
|
783
|
+
close: () => {},
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
stopSession(chatId: string) {
|
|
788
|
+
const context = this.sessions.get(chatId)
|
|
789
|
+
if (!context) return
|
|
790
|
+
context.closed = true
|
|
791
|
+
context.pendingTurn?.queue.finish()
|
|
792
|
+
this.sessions.delete(chatId)
|
|
793
|
+
try {
|
|
794
|
+
context.child.kill("SIGKILL")
|
|
795
|
+
} catch {
|
|
796
|
+
// ignore kill failures
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
stopAll() {
|
|
801
|
+
for (const chatId of this.sessions.keys()) {
|
|
802
|
+
this.stopSession(chatId)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private requireSession(chatId: string) {
|
|
807
|
+
const context = this.sessions.get(chatId)
|
|
808
|
+
if (!context || context.closed) {
|
|
809
|
+
throw new Error("Codex session not started")
|
|
810
|
+
}
|
|
811
|
+
return context
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private attachListeners(context: SessionContext) {
|
|
815
|
+
const lines = createInterface({ input: context.child.stdout })
|
|
816
|
+
void (async () => {
|
|
817
|
+
for await (const line of lines) {
|
|
818
|
+
const parsed = parseJsonLine(line)
|
|
819
|
+
if (!parsed) continue
|
|
820
|
+
|
|
821
|
+
if (isJsonRpcResponse(parsed)) {
|
|
822
|
+
this.handleResponse(context, parsed)
|
|
823
|
+
continue
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (isServerRequest(parsed)) {
|
|
827
|
+
void this.handleServerRequest(context, parsed)
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (isServerNotification(parsed)) {
|
|
832
|
+
void this.handleNotification(context, parsed)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
})()
|
|
836
|
+
|
|
837
|
+
const stderr = createInterface({ input: context.child.stderr })
|
|
838
|
+
void (async () => {
|
|
839
|
+
for await (const line of stderr) {
|
|
840
|
+
if (line.trim()) {
|
|
841
|
+
context.stderrLines.push(line.trim())
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
})()
|
|
845
|
+
|
|
846
|
+
context.child.on("error", (error) => {
|
|
847
|
+
this.failContext(context, error.message)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
context.child.on("close", (code) => {
|
|
851
|
+
if (context.closed) return
|
|
852
|
+
queueMicrotask(() => {
|
|
853
|
+
if (context.closed) return
|
|
854
|
+
const message = context.stderrLines.at(-1) || `Codex app-server exited with code ${code ?? 1}`
|
|
855
|
+
this.failContext(context, message)
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private handleResponse(context: SessionContext, response: JsonRpcResponse) {
|
|
861
|
+
const pending = context.pendingRequests.get(response.id)
|
|
862
|
+
if (!pending) return
|
|
863
|
+
context.pendingRequests.delete(response.id)
|
|
864
|
+
if (response.error) {
|
|
865
|
+
pending.reject(new Error(`${pending.method} failed: ${response.error.message ?? "Unknown error"}`))
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
pending.resolve(response.result)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private async handleServerRequest(context: SessionContext, request: ServerRequest) {
|
|
872
|
+
const pendingTurn = context.pendingTurn
|
|
873
|
+
if (!pendingTurn) {
|
|
874
|
+
this.writeMessage(context, {
|
|
875
|
+
id: request.id,
|
|
876
|
+
error: {
|
|
877
|
+
message: "No active turn",
|
|
878
|
+
},
|
|
879
|
+
})
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (request.method === "item/tool/requestUserInput") {
|
|
884
|
+
const questions = toAskUserQuestionItems(request.params)
|
|
885
|
+
const toolId = request.params.itemId
|
|
886
|
+
const toolRequest: HarnessToolRequest = {
|
|
887
|
+
tool: {
|
|
888
|
+
kind: "tool",
|
|
889
|
+
toolKind: "ask_user_question",
|
|
890
|
+
toolName: "AskUserQuestion",
|
|
891
|
+
toolId,
|
|
892
|
+
input: { questions },
|
|
893
|
+
rawInput: {
|
|
894
|
+
questions: request.params.questions,
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
}
|
|
898
|
+
pendingTurn.queue.push({
|
|
899
|
+
type: "transcript",
|
|
900
|
+
entry: timestamped({
|
|
901
|
+
kind: "tool_call",
|
|
902
|
+
tool: toolRequest.tool,
|
|
903
|
+
}),
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
const result = await pendingTurn.onToolRequest(toolRequest)
|
|
907
|
+
this.writeMessage(context, {
|
|
908
|
+
id: request.id,
|
|
909
|
+
result: toToolRequestUserInputResponse(result, request.params.questions),
|
|
910
|
+
})
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (request.method === "item/tool/call") {
|
|
915
|
+
pendingTurn.handledDynamicToolIds.add(request.params.callId)
|
|
916
|
+
if (request.params.tool === "update_plan") {
|
|
917
|
+
const args = asRecord(request.params.arguments)
|
|
918
|
+
const plan = Array.isArray(args?.plan) ? args.plan : []
|
|
919
|
+
const steps: TurnPlanStep[] = plan
|
|
920
|
+
.map((entry) => asRecord(entry))
|
|
921
|
+
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
|
|
922
|
+
.map((entry) => {
|
|
923
|
+
const status: TurnPlanStep["status"] =
|
|
924
|
+
entry.status === "completed"
|
|
925
|
+
? "completed"
|
|
926
|
+
: entry.status === "inProgress" || entry.status === "in_progress"
|
|
927
|
+
? "inProgress"
|
|
928
|
+
: "pending"
|
|
929
|
+
return {
|
|
930
|
+
step: typeof entry.step === "string" ? entry.step : "",
|
|
931
|
+
status,
|
|
932
|
+
}
|
|
933
|
+
})
|
|
934
|
+
.filter((step) => step.step.length > 0)
|
|
935
|
+
|
|
936
|
+
if (steps.length > 0) {
|
|
937
|
+
pendingTurn.latestPlanSteps = steps
|
|
938
|
+
pendingTurn.latestPlanExplanation = typeof args?.explanation === "string" ? args.explanation : pendingTurn.latestPlanExplanation
|
|
939
|
+
pendingTurn.queue.push({
|
|
940
|
+
type: "transcript",
|
|
941
|
+
entry: todoToolCall(request.params.callId, steps),
|
|
942
|
+
})
|
|
943
|
+
pendingTurn.queue.push({
|
|
944
|
+
type: "transcript",
|
|
945
|
+
entry: timestamped({
|
|
946
|
+
kind: "tool_result",
|
|
947
|
+
toolId: request.params.callId,
|
|
948
|
+
content: "",
|
|
949
|
+
}),
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
this.writeMessage(context, {
|
|
954
|
+
id: request.id,
|
|
955
|
+
result: {
|
|
956
|
+
contentItems: [],
|
|
957
|
+
success: true,
|
|
958
|
+
} satisfies DynamicToolCallResponse,
|
|
959
|
+
})
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const payload = dynamicToolPayload(request.params.arguments)
|
|
964
|
+
pendingTurn.queue.push({
|
|
965
|
+
type: "transcript",
|
|
966
|
+
entry: genericDynamicToolCall(request.params.callId, request.params.tool, payload),
|
|
967
|
+
})
|
|
968
|
+
const errorMessage = `Unsupported dynamic tool call: ${request.params.tool}`
|
|
969
|
+
pendingTurn.queue.push({
|
|
970
|
+
type: "transcript",
|
|
971
|
+
entry: timestamped({
|
|
972
|
+
kind: "tool_result",
|
|
973
|
+
toolId: request.params.callId,
|
|
974
|
+
content: errorMessage,
|
|
975
|
+
isError: true,
|
|
976
|
+
}),
|
|
977
|
+
})
|
|
978
|
+
this.writeMessage(context, {
|
|
979
|
+
id: request.id,
|
|
980
|
+
result: {
|
|
981
|
+
contentItems: [{ type: "inputText", text: errorMessage }],
|
|
982
|
+
success: false,
|
|
983
|
+
} satisfies DynamicToolCallResponse,
|
|
984
|
+
})
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (request.method === "item/commandExecution/requestApproval") {
|
|
989
|
+
const decision = await pendingTurn.onApprovalRequest?.({
|
|
990
|
+
requestId: request.id,
|
|
991
|
+
kind: "command_execution",
|
|
992
|
+
params: request.params,
|
|
993
|
+
}) ?? "decline"
|
|
994
|
+
this.writeMessage(context, {
|
|
995
|
+
id: request.id,
|
|
996
|
+
result: {
|
|
997
|
+
decision,
|
|
998
|
+
} satisfies CommandExecutionRequestApprovalResponse,
|
|
999
|
+
})
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const decision = await pendingTurn.onApprovalRequest?.({
|
|
1004
|
+
requestId: request.id,
|
|
1005
|
+
kind: "file_change",
|
|
1006
|
+
params: request.params,
|
|
1007
|
+
}) ?? "decline"
|
|
1008
|
+
this.writeMessage(context, {
|
|
1009
|
+
id: request.id,
|
|
1010
|
+
result: {
|
|
1011
|
+
decision,
|
|
1012
|
+
} satisfies FileChangeRequestApprovalResponse,
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private async handleNotification(context: SessionContext, notification: ServerNotification) {
|
|
1017
|
+
if (notification.method === "thread/started") {
|
|
1018
|
+
context.sessionToken = notification.params.thread.id
|
|
1019
|
+
if (context.pendingTurn) {
|
|
1020
|
+
context.pendingTurn.queue.push({
|
|
1021
|
+
type: "session_token",
|
|
1022
|
+
sessionToken: notification.params.thread.id,
|
|
1023
|
+
})
|
|
1024
|
+
}
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const pendingTurn = context.pendingTurn
|
|
1029
|
+
if (!pendingTurn) return
|
|
1030
|
+
|
|
1031
|
+
switch (notification.method) {
|
|
1032
|
+
case "turn/plan/updated":
|
|
1033
|
+
this.handlePlanUpdated(pendingTurn, notification.params)
|
|
1034
|
+
return
|
|
1035
|
+
case "item/started":
|
|
1036
|
+
this.handleItemStarted(pendingTurn, notification.params)
|
|
1037
|
+
return
|
|
1038
|
+
case "item/completed":
|
|
1039
|
+
this.handleItemCompleted(pendingTurn, notification.params)
|
|
1040
|
+
return
|
|
1041
|
+
case "item/plan/delta":
|
|
1042
|
+
this.handlePlanDelta(pendingTurn, notification.params)
|
|
1043
|
+
return
|
|
1044
|
+
case "turn/completed":
|
|
1045
|
+
await this.handleTurnCompleted(context, notification.params)
|
|
1046
|
+
return
|
|
1047
|
+
case "thread/compacted":
|
|
1048
|
+
this.handleContextCompacted(pendingTurn, notification.params)
|
|
1049
|
+
return
|
|
1050
|
+
case "error":
|
|
1051
|
+
this.failContext(context, notification.params.message)
|
|
1052
|
+
return
|
|
1053
|
+
default:
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private handleItemStarted(pendingTurn: PendingTurn, notification: ItemStartedNotification) {
|
|
1059
|
+
if (notification.item.type === "plan") {
|
|
1060
|
+
pendingTurn.planTextByItemId.set(notification.item.id, notification.item.text)
|
|
1061
|
+
pendingTurn.latestPlanText = notification.item.text
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (
|
|
1066
|
+
notification.item.type === "commandExecution"
|
|
1067
|
+
|| notification.item.type === "webSearch"
|
|
1068
|
+
|| notification.item.type === "mcpToolCall"
|
|
1069
|
+
|| notification.item.type === "dynamicToolCall"
|
|
1070
|
+
|| notification.item.type === "collabAgentToolCall"
|
|
1071
|
+
|| notification.item.type === "fileChange"
|
|
1072
|
+
|| notification.item.type === "error"
|
|
1073
|
+
) {
|
|
1074
|
+
if (pendingTurn.handledDynamicToolIds.has(notification.item.id)) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (notification.item.type === "webSearch" && !webSearchQuery(notification.item)) {
|
|
1078
|
+
return
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const entries = itemToToolCalls(notification.item)
|
|
1083
|
+
for (const entry of entries) {
|
|
1084
|
+
if (entry.kind === "tool_call") {
|
|
1085
|
+
pendingTurn.startedToolIds.add(entry.tool.toolId)
|
|
1086
|
+
}
|
|
1087
|
+
pendingTurn.queue.push({ type: "transcript", entry })
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private handleItemCompleted(pendingTurn: PendingTurn, notification: ItemCompletedNotification) {
|
|
1092
|
+
if (notification.item.type === "agentMessage") {
|
|
1093
|
+
pendingTurn.queue.push({
|
|
1094
|
+
type: "transcript",
|
|
1095
|
+
entry: timestamped({
|
|
1096
|
+
kind: "assistant_text",
|
|
1097
|
+
text: notification.item.text,
|
|
1098
|
+
}),
|
|
1099
|
+
})
|
|
1100
|
+
if (pendingTurn.pendingWebSearchResultToolId && notification.item.text.trim()) {
|
|
1101
|
+
pendingTurn.queue.push({
|
|
1102
|
+
type: "transcript",
|
|
1103
|
+
entry: timestamped({
|
|
1104
|
+
kind: "tool_result",
|
|
1105
|
+
toolId: pendingTurn.pendingWebSearchResultToolId,
|
|
1106
|
+
content: notification.item.text,
|
|
1107
|
+
}),
|
|
1108
|
+
})
|
|
1109
|
+
pendingTurn.pendingWebSearchResultToolId = null
|
|
1110
|
+
}
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (notification.item.type === "plan") {
|
|
1115
|
+
pendingTurn.planTextByItemId.set(notification.item.id, notification.item.text)
|
|
1116
|
+
pendingTurn.latestPlanText = notification.item.text
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (pendingTurn.handledDynamicToolIds.has(notification.item.id)) {
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const startedEntries = itemToToolCalls(notification.item)
|
|
1125
|
+
for (const entry of startedEntries) {
|
|
1126
|
+
if (entry.kind !== "tool_call") {
|
|
1127
|
+
continue
|
|
1128
|
+
}
|
|
1129
|
+
if (pendingTurn.startedToolIds.has(entry.tool.toolId)) {
|
|
1130
|
+
continue
|
|
1131
|
+
}
|
|
1132
|
+
pendingTurn.startedToolIds.add(entry.tool.toolId)
|
|
1133
|
+
pendingTurn.queue.push({ type: "transcript", entry })
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const resultEntries = itemToToolResults(notification.item)
|
|
1137
|
+
for (const entry of resultEntries) {
|
|
1138
|
+
pendingTurn.queue.push({ type: "transcript", entry })
|
|
1139
|
+
if (notification.item.type === "webSearch" && entry.kind === "tool_result" && !entry.isError) {
|
|
1140
|
+
pendingTurn.pendingWebSearchResultToolId = notification.item.id
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private handlePlanUpdated(pendingTurn: PendingTurn, notification: TurnPlanUpdatedNotification) {
|
|
1146
|
+
pendingTurn.latestPlanExplanation = notification.explanation ?? null
|
|
1147
|
+
pendingTurn.latestPlanSteps = notification.plan
|
|
1148
|
+
if (notification.plan.length === 0) {
|
|
1149
|
+
return
|
|
1150
|
+
}
|
|
1151
|
+
pendingTurn.todoSequence += 1
|
|
1152
|
+
pendingTurn.queue.push({
|
|
1153
|
+
type: "transcript",
|
|
1154
|
+
entry: todoToolCall(
|
|
1155
|
+
`${notification.turnId}:todo-${pendingTurn.todoSequence}`,
|
|
1156
|
+
notification.plan
|
|
1157
|
+
),
|
|
1158
|
+
})
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private handlePlanDelta(pendingTurn: PendingTurn, notification: PlanDeltaNotification) {
|
|
1162
|
+
const current = pendingTurn.planTextByItemId.get(notification.itemId) ?? ""
|
|
1163
|
+
const next = `${current}${notification.delta}`
|
|
1164
|
+
pendingTurn.planTextByItemId.set(notification.itemId, next)
|
|
1165
|
+
pendingTurn.latestPlanText = next
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private handleContextCompacted(pendingTurn: PendingTurn, _notification: ContextCompactedNotification) {
|
|
1169
|
+
pendingTurn.queue.push({
|
|
1170
|
+
type: "transcript",
|
|
1171
|
+
entry: timestamped({ kind: "compact_boundary" }),
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
private async handleTurnCompleted(context: SessionContext, notification: TurnCompletedNotification) {
|
|
1176
|
+
const pendingTurn = context.pendingTurn
|
|
1177
|
+
if (!pendingTurn) return
|
|
1178
|
+
const status = notification.turn.status
|
|
1179
|
+
const isCancelled = status === "interrupted"
|
|
1180
|
+
const isError = status === "failed"
|
|
1181
|
+
pendingTurn.pendingWebSearchResultToolId = null
|
|
1182
|
+
|
|
1183
|
+
if (!isCancelled && !isError && pendingTurn.planMode) {
|
|
1184
|
+
const planText = pendingTurn.latestPlanText?.trim()
|
|
1185
|
+
|| renderPlanMarkdownFromSteps(pendingTurn.latestPlanSteps).trim()
|
|
1186
|
+
|
|
1187
|
+
if (planText) {
|
|
1188
|
+
pendingTurn.turnId = null
|
|
1189
|
+
const tool = {
|
|
1190
|
+
kind: "tool" as const,
|
|
1191
|
+
toolKind: "exit_plan_mode" as const,
|
|
1192
|
+
toolName: "ExitPlanMode",
|
|
1193
|
+
toolId: `${notification.turn.id}:exit-plan`,
|
|
1194
|
+
input: {
|
|
1195
|
+
plan: planText,
|
|
1196
|
+
summary: pendingTurn.latestPlanExplanation ?? undefined,
|
|
1197
|
+
},
|
|
1198
|
+
rawInput: {
|
|
1199
|
+
plan: planText,
|
|
1200
|
+
summary: pendingTurn.latestPlanExplanation ?? undefined,
|
|
1201
|
+
},
|
|
1202
|
+
}
|
|
1203
|
+
pendingTurn.queue.push({
|
|
1204
|
+
type: "transcript",
|
|
1205
|
+
entry: timestamped({
|
|
1206
|
+
kind: "tool_call",
|
|
1207
|
+
tool,
|
|
1208
|
+
}),
|
|
1209
|
+
})
|
|
1210
|
+
await pendingTurn.onToolRequest({ tool })
|
|
1211
|
+
pendingTurn.resolved = true
|
|
1212
|
+
pendingTurn.queue.finish()
|
|
1213
|
+
context.pendingTurn = null
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
pendingTurn.resolved = true
|
|
1219
|
+
pendingTurn.queue.push({
|
|
1220
|
+
type: "transcript",
|
|
1221
|
+
entry: timestamped({
|
|
1222
|
+
kind: "result",
|
|
1223
|
+
subtype: isCancelled ? "cancelled" : isError ? "error" : "success",
|
|
1224
|
+
isError,
|
|
1225
|
+
durationMs: 0,
|
|
1226
|
+
result: notification.turn.error?.message ?? "",
|
|
1227
|
+
}),
|
|
1228
|
+
})
|
|
1229
|
+
pendingTurn.queue.finish()
|
|
1230
|
+
context.pendingTurn = null
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private failContext(context: SessionContext, message: string) {
|
|
1234
|
+
const pendingTurn = context.pendingTurn
|
|
1235
|
+
if (pendingTurn && !pendingTurn.resolved) {
|
|
1236
|
+
pendingTurn.queue.push({
|
|
1237
|
+
type: "transcript",
|
|
1238
|
+
entry: timestamped({
|
|
1239
|
+
kind: "result",
|
|
1240
|
+
subtype: "error",
|
|
1241
|
+
isError: true,
|
|
1242
|
+
durationMs: 0,
|
|
1243
|
+
result: message,
|
|
1244
|
+
}),
|
|
1245
|
+
})
|
|
1246
|
+
pendingTurn.queue.finish()
|
|
1247
|
+
context.pendingTurn = null
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
for (const pending of context.pendingRequests.values()) {
|
|
1251
|
+
pending.reject(new Error(message))
|
|
1252
|
+
}
|
|
1253
|
+
context.pendingRequests.clear()
|
|
1254
|
+
context.closed = true
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private async sendRequest<TResult>(context: SessionContext, method: string, params: unknown): Promise<TResult> {
|
|
1258
|
+
const id = randomUUID()
|
|
1259
|
+
const promise = new Promise<TResult>((resolve, reject) => {
|
|
1260
|
+
context.pendingRequests.set(id, {
|
|
1261
|
+
method,
|
|
1262
|
+
resolve: resolve as (value: unknown) => void,
|
|
1263
|
+
reject,
|
|
1264
|
+
})
|
|
1265
|
+
})
|
|
1266
|
+
this.writeMessage(context, {
|
|
1267
|
+
id,
|
|
1268
|
+
method,
|
|
1269
|
+
params,
|
|
1270
|
+
})
|
|
1271
|
+
return await promise
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private writeMessage(context: SessionContext, message: Record<string, unknown>) {
|
|
1275
|
+
context.child.stdin.write(`${JSON.stringify(message)}\n`)
|
|
1276
|
+
}
|
|
1277
|
+
}
|