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