kaizenai 0.1.0

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