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.
@@ -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
+ }