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.
@@ -1,13 +1,25 @@
1
1
  import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
2
+ import type {
3
+ AgentProvider,
4
+ NormalizedToolCall,
5
+ PendingToolSnapshot,
6
+ KannaStatus,
7
+ TranscriptEntry,
8
+ } from "../shared/types"
9
+ import { normalizeToolCall } from "../shared/tools"
2
10
  import type { ClientCommand } from "../shared/protocol"
3
-
4
- import type { KannaStatus, PendingToolSnapshot } from "../shared/types"
5
11
  import { EventStore } from "./event-store"
6
- import { generateTitleForChat } from "./generate-title"
7
-
8
- const DEFAULT_MODEL = "opus"
9
-
10
- const TOOLSET = [
12
+ import { CodexAppServerManager } from "./codex-app-server"
13
+ import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
14
+ import {
15
+ codexServiceTierFromModelOptions,
16
+ getServerProviderCatalog,
17
+ normalizeClaudeModelOptions,
18
+ normalizeCodexModelOptions,
19
+ normalizeServerModel,
20
+ } from "./provider-catalog"
21
+
22
+ const CLAUDE_TOOLSET = [
11
23
  "Skill",
12
24
  "WebFetch",
13
25
  "WebSearch",
@@ -28,16 +40,21 @@ const TOOLSET = [
28
40
 
29
41
  interface PendingToolRequest {
30
42
  toolUseId: string
31
- toolName: "AskUserQuestion" | "ExitPlanMode"
32
- input: Record<string, unknown>
33
- resolve: (result: PermissionResult) => void
43
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
44
+ resolve: (result: unknown) => void
34
45
  }
35
46
 
36
47
  interface ActiveTurn {
37
48
  chatId: string
38
- query: Query
49
+ provider: AgentProvider
50
+ turn: HarnessTurn
51
+ model: string
52
+ effort?: string
53
+ serviceTier?: "fast"
54
+ planMode: boolean
39
55
  status: KannaStatus
40
56
  pendingTool: PendingToolRequest | null
57
+ postToolFollowUp: { content: string; planMode: boolean } | null
41
58
  hasFinalResult: boolean
42
59
  cancelRequested: boolean
43
60
  cancelRecorded: boolean
@@ -46,70 +63,270 @@ interface ActiveTurn {
46
63
  interface AgentCoordinatorArgs {
47
64
  store: EventStore
48
65
  onStateChange: () => void
66
+ codexManager?: CodexAppServerManager
49
67
  }
50
68
 
51
- function buildUserPromptPayload(content: string) {
52
- return JSON.stringify({ type: "user_prompt", content })
69
+ function deriveChatTitle(content: string) {
70
+ const singleLine = content.replace(/\s+/g, " ").trim()
71
+ return singleLine.slice(0, 60) || "New Chat"
53
72
  }
54
73
 
55
- function buildToolResultPayload(toolUseId: string, body: unknown) {
56
- return JSON.stringify({
57
- type: "user",
58
- message: {
59
- role: "user",
60
- content: [
61
- {
62
- type: "tool_result",
63
- tool_use_id: toolUseId,
64
- content: JSON.stringify(body),
65
- },
66
- ],
67
- },
68
- })
74
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
75
+ entry: T,
76
+ createdAt = Date.now()
77
+ ): TranscriptEntry {
78
+ return {
79
+ _id: crypto.randomUUID(),
80
+ createdAt,
81
+ ...entry,
82
+ } as TranscriptEntry
69
83
  }
70
84
 
71
- function buildCancelledPayload() {
72
- return JSON.stringify({
73
- type: "result",
74
- subtype: "cancelled",
75
- is_error: false,
76
- duration_ms: 0,
77
- result: "Interrupted by user",
78
- })
85
+ function stringFromUnknown(value: unknown) {
86
+ if (typeof value === "string") return value
87
+ try {
88
+ return JSON.stringify(value, null, 2)
89
+ } catch {
90
+ return String(value)
91
+ }
79
92
  }
80
93
 
81
- function buildErrorPayload(message: string) {
82
- return JSON.stringify({
83
- type: "result",
84
- subtype: "error",
85
- is_error: true,
86
- duration_ms: 0,
87
- result: message,
88
- })
94
+ export function normalizeClaudeStreamMessage(message: any): TranscriptEntry[] {
95
+ const debugRaw = JSON.stringify(message)
96
+ const messageId = typeof message.uuid === "string" ? message.uuid : undefined
97
+
98
+ if (message.type === "system" && message.subtype === "init") {
99
+ return [
100
+ timestamped({
101
+ kind: "system_init",
102
+ messageId,
103
+ provider: "claude",
104
+ model: typeof message.model === "string" ? message.model : "unknown",
105
+ tools: Array.isArray(message.tools) ? message.tools : [],
106
+ agents: Array.isArray(message.agents) ? message.agents : [],
107
+ slashCommands: Array.isArray(message.slash_commands)
108
+ ? message.slash_commands.filter((entry: string) => !entry.startsWith("._"))
109
+ : [],
110
+ mcpServers: Array.isArray(message.mcp_servers) ? message.mcp_servers : [],
111
+ debugRaw,
112
+ }),
113
+ ]
114
+ }
115
+
116
+ if (message.type === "assistant" && Array.isArray(message.message?.content)) {
117
+ const entries: TranscriptEntry[] = []
118
+ for (const content of message.message.content) {
119
+ if (content.type === "text" && typeof content.text === "string") {
120
+ entries.push(timestamped({
121
+ kind: "assistant_text",
122
+ messageId,
123
+ text: content.text,
124
+ debugRaw,
125
+ }))
126
+ }
127
+ if (content.type === "tool_use" && typeof content.name === "string" && typeof content.id === "string") {
128
+ entries.push(timestamped({
129
+ kind: "tool_call",
130
+ messageId,
131
+ tool: normalizeToolCall({
132
+ toolName: content.name,
133
+ toolId: content.id,
134
+ input: (content.input ?? {}) as Record<string, unknown>,
135
+ }),
136
+ debugRaw,
137
+ }))
138
+ }
139
+ }
140
+ return entries
141
+ }
142
+
143
+ if (message.type === "user" && Array.isArray(message.message?.content)) {
144
+ const entries: TranscriptEntry[] = []
145
+ for (const content of message.message.content) {
146
+ if (content.type === "tool_result" && typeof content.tool_use_id === "string") {
147
+ entries.push(timestamped({
148
+ kind: "tool_result",
149
+ messageId,
150
+ toolId: content.tool_use_id,
151
+ content: content.content,
152
+ isError: Boolean(content.is_error),
153
+ debugRaw,
154
+ }))
155
+ }
156
+ if (message.message.role === "user" && typeof message.message.content === "string") {
157
+ entries.push(timestamped({
158
+ kind: "compact_summary",
159
+ messageId,
160
+ summary: message.message.content,
161
+ debugRaw,
162
+ }))
163
+ }
164
+ }
165
+ return entries
166
+ }
167
+
168
+ if (message.type === "result") {
169
+ if (message.subtype === "cancelled") {
170
+ return [timestamped({ kind: "interrupted", messageId, debugRaw })]
171
+ }
172
+ return [
173
+ timestamped({
174
+ kind: "result",
175
+ messageId,
176
+ subtype: message.is_error ? "error" : "success",
177
+ isError: Boolean(message.is_error),
178
+ durationMs: typeof message.duration_ms === "number" ? message.duration_ms : 0,
179
+ result: typeof message.result === "string" ? message.result : stringFromUnknown(message.result),
180
+ costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined,
181
+ debugRaw,
182
+ }),
183
+ ]
184
+ }
185
+
186
+ if (message.type === "system" && message.subtype === "status" && typeof message.status === "string") {
187
+ return [timestamped({ kind: "status", messageId, status: message.status, debugRaw })]
188
+ }
189
+
190
+ if (message.type === "system" && message.subtype === "compact_boundary") {
191
+ return [timestamped({ kind: "compact_boundary", messageId, debugRaw })]
192
+ }
193
+
194
+ if (message.type === "system" && message.subtype === "context_cleared") {
195
+ return [timestamped({ kind: "context_cleared", messageId, debugRaw })]
196
+ }
197
+
198
+ if (
199
+ message.type === "user" &&
200
+ message.message?.role === "user" &&
201
+ typeof message.message.content === "string" &&
202
+ message.message.content.startsWith("This session is being continued")
203
+ ) {
204
+ return [timestamped({ kind: "compact_summary", messageId, summary: message.message.content, debugRaw })]
205
+ }
206
+
207
+ return []
89
208
  }
90
209
 
91
- function deriveChatTitle(content: string) {
92
- const singleLine = content.replace(/\s+/g, " ").trim()
93
- return singleLine.slice(0, 60) || "New Chat"
210
+ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent> {
211
+ for await (const sdkMessage of q as AsyncIterable<any>) {
212
+ const sessionToken = typeof sdkMessage.session_id === "string" ? sdkMessage.session_id : null
213
+ if (sessionToken) {
214
+ yield { type: "session_token", sessionToken }
215
+ }
216
+ for (const entry of normalizeClaudeStreamMessage(sdkMessage)) {
217
+ yield { type: "transcript", entry }
218
+ }
219
+ }
94
220
  }
95
221
 
96
- function toTranscriptEntry(message: string, messageId?: string) {
222
+ async function startClaudeTurn(args: {
223
+ content: string
224
+ localPath: string
225
+ model: string
226
+ effort?: string
227
+ planMode: boolean
228
+ sessionToken: string | null
229
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
230
+ }): Promise<HarnessTurn> {
231
+ const canUseTool: CanUseTool = async (toolName, input, options) => {
232
+ if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
233
+ return {
234
+ behavior: "allow",
235
+ updatedInput: input,
236
+ }
237
+ }
238
+
239
+ const tool = normalizeToolCall({
240
+ toolName,
241
+ toolId: options.toolUseID,
242
+ input: (input ?? {}) as Record<string, unknown>,
243
+ })
244
+
245
+ if (tool.toolKind !== "ask_user_question" && tool.toolKind !== "exit_plan_mode") {
246
+ return {
247
+ behavior: "deny",
248
+ message: "Unsupported tool request",
249
+ }
250
+ }
251
+
252
+ const result = await args.onToolRequest({ tool })
253
+
254
+ if (tool.toolKind === "ask_user_question") {
255
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
256
+ return {
257
+ behavior: "allow",
258
+ updatedInput: {
259
+ ...(tool.rawInput ?? {}),
260
+ questions: record.questions ?? tool.input.questions,
261
+ answers: record.answers ?? result,
262
+ },
263
+ } satisfies PermissionResult
264
+ }
265
+
266
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
267
+ const confirmed = Boolean(record.confirmed)
268
+ if (confirmed) {
269
+ return {
270
+ behavior: "allow",
271
+ updatedInput: {
272
+ ...(tool.rawInput ?? {}),
273
+ ...record,
274
+ },
275
+ } satisfies PermissionResult
276
+ }
277
+
278
+ return {
279
+ behavior: "deny",
280
+ message: typeof record.message === "string"
281
+ ? `User wants to suggest edits to the plan: ${record.message}`
282
+ : "User wants to suggest edits to the plan before approving.",
283
+ } satisfies PermissionResult
284
+ }
285
+
286
+ const q = query({
287
+ prompt: args.content,
288
+ options: {
289
+ cwd: args.localPath,
290
+ model: args.model,
291
+ effort: args.effort as "low" | "medium" | "high" | "max" | undefined,
292
+ resume: args.sessionToken ?? undefined,
293
+ permissionMode: args.planMode ? "plan" : "acceptEdits",
294
+ canUseTool,
295
+ tools: [...CLAUDE_TOOLSET],
296
+ settingSources: ["user", "project", "local"],
297
+ env: { ...process.env },
298
+ },
299
+ })
300
+
97
301
  return {
98
- _id: crypto.randomUUID(),
99
- messageId,
100
- message,
101
- createdAt: Date.now(),
302
+ provider: "claude",
303
+ stream: createClaudeHarnessStream(q),
304
+ getAccountInfo: async () => {
305
+ try {
306
+ return await q.accountInfo()
307
+ } catch {
308
+ return null
309
+ }
310
+ },
311
+ interrupt: async () => {
312
+ await q.interrupt()
313
+ },
314
+ close: () => {
315
+ q.close()
316
+ },
102
317
  }
103
318
  }
104
319
 
105
320
  export class AgentCoordinator {
106
321
  private readonly store: EventStore
107
322
  private readonly onStateChange: () => void
323
+ private readonly codexManager: CodexAppServerManager
108
324
  readonly activeTurns = new Map<string, ActiveTurn>()
109
325
 
110
326
  constructor(args: AgentCoordinatorArgs) {
111
327
  this.store = args.store
112
328
  this.onStateChange = args.onStateChange
329
+ this.codexManager = args.codexManager ?? new CodexAppServerManager()
113
330
  }
114
331
 
115
332
  getActiveStatuses() {
@@ -123,153 +340,197 @@ export class AgentCoordinator {
123
340
  getPendingTool(chatId: string): PendingToolSnapshot | null {
124
341
  const pending = this.activeTurns.get(chatId)?.pendingTool
125
342
  if (!pending) return null
126
- return { toolUseId: pending.toolUseId, toolName: pending.toolName }
343
+ return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
127
344
  }
128
345
 
129
- async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
130
- let chatId = command.chatId
346
+ private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
347
+ if (currentProvider) return currentProvider
348
+ return command.provider ?? "claude"
349
+ }
131
350
 
132
- if (!chatId) {
133
- if (!command.projectId) {
134
- throw new Error("Missing projectId for new chat")
351
+ private getProviderSettings(provider: AgentProvider, command: Extract<ClientCommand, { type: "chat.send" }>) {
352
+ const catalog = getServerProviderCatalog(provider)
353
+ if (provider === "claude") {
354
+ const modelOptions = normalizeClaudeModelOptions(command.modelOptions, command.effort)
355
+ return {
356
+ model: normalizeServerModel(provider, command.model),
357
+ effort: modelOptions.reasoningEffort,
358
+ serviceTier: undefined,
359
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
135
360
  }
136
- const created = await this.store.createChat(command.projectId)
137
- chatId = created.id
138
361
  }
139
362
 
140
- const chat = this.store.requireChat(chatId)
141
- if (this.activeTurns.has(chatId)) {
363
+ const modelOptions = normalizeCodexModelOptions(command.modelOptions, command.effort)
364
+ return {
365
+ model: normalizeServerModel(provider, command.model),
366
+ effort: modelOptions.reasoningEffort,
367
+ serviceTier: codexServiceTierFromModelOptions(modelOptions),
368
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
369
+ }
370
+ }
371
+
372
+ private async startTurnForChat(args: {
373
+ chatId: string
374
+ provider: AgentProvider
375
+ content: string
376
+ model: string
377
+ effort?: string
378
+ serviceTier?: "fast"
379
+ planMode: boolean
380
+ appendUserPrompt: boolean
381
+ }) {
382
+ const chat = this.store.requireChat(args.chatId)
383
+ if (this.activeTurns.has(args.chatId)) {
142
384
  throw new Error("Chat is already running")
143
385
  }
144
386
 
145
- const existingMessages = this.store.getMessages(chatId)
146
- if (chat.title === "New Chat" && existingMessages.length === 0) {
147
- // Immediate placeholder: truncated first message
148
- await this.store.renameChat(chatId, deriveChatTitle(command.content))
149
-
150
- // Fire-and-forget: generate a better title with Haiku in parallel
151
- void generateTitleForChat(command.content)
152
- .then(async (title) => {
153
- if (title) {
154
- await this.store.renameChat(chatId!, title)
155
- this.onStateChange()
156
- }
157
- })
158
- .catch(() => undefined)
387
+ if (!chat.provider) {
388
+ await this.store.setChatProvider(args.chatId, args.provider)
159
389
  }
390
+ await this.store.setPlanMode(args.chatId, args.planMode)
160
391
 
161
- await this.store.appendMessage(chatId, toTranscriptEntry(buildUserPromptPayload(command.content), crypto.randomUUID()))
162
- await this.store.recordTurnStarted(chatId)
392
+ const existingMessages = this.store.getMessages(args.chatId)
393
+ if (args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0) {
394
+ await this.store.renameChat(args.chatId, deriveChatTitle(args.content))
395
+ }
163
396
 
164
- const canUseTool: CanUseTool = async (toolName, input, options) => {
165
- if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
166
- return {
167
- behavior: "allow",
168
- updatedInput: input,
169
- }
170
- }
397
+ if (args.appendUserPrompt) {
398
+ await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
399
+ }
400
+ await this.store.recordTurnStarted(args.chatId)
171
401
 
172
- const active = this.activeTurns.get(chatId!)
402
+ const project = this.store.getProject(chat.projectId)
403
+ if (!project) {
404
+ throw new Error("Project not found")
405
+ }
406
+
407
+ const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
408
+ const active = this.activeTurns.get(args.chatId)
173
409
  if (!active) {
174
- return {
175
- behavior: "deny",
176
- message: "Chat turn ended unexpectedly",
177
- }
410
+ throw new Error("Chat turn ended unexpectedly")
178
411
  }
179
412
 
180
413
  active.status = "waiting_for_user"
181
414
  this.onStateChange()
182
415
 
183
- return await new Promise<PermissionResult>((resolve) => {
416
+ return await new Promise<unknown>((resolve) => {
184
417
  active.pendingTool = {
185
- toolUseId: options.toolUseID,
186
- toolName,
187
- input,
418
+ toolUseId: request.tool.toolId,
419
+ tool: request.tool,
188
420
  resolve,
189
421
  }
190
422
  })
191
423
  }
192
424
 
193
- const project = this.store.getProject(chat.projectId)
194
- if (!project) {
195
- throw new Error("Project not found")
196
- }
197
-
198
- const resolvedModel = command.model || DEFAULT_MODEL
199
- const resolvedEffort = (command.effort as "low" | "medium" | "high" | "max") || undefined
200
- console.log(`[agent] query model=${resolvedModel} effort=${resolvedEffort} (command.model=${command.model})`)
201
-
202
- const q = query({
203
- prompt: command.content,
204
- options: {
425
+ let turn: HarnessTurn
426
+ if (args.provider === "claude") {
427
+ turn = await startClaudeTurn({
428
+ content: args.content,
429
+ localPath: project.localPath,
430
+ model: args.model,
431
+ effort: args.effort,
432
+ planMode: args.planMode,
433
+ sessionToken: chat.sessionToken,
434
+ onToolRequest,
435
+ })
436
+ } else {
437
+ await this.codexManager.startSession({
438
+ chatId: args.chatId,
205
439
  cwd: project.localPath,
206
- model: resolvedModel,
207
- effort: resolvedEffort,
208
- resume: chat.resumeSessionId ?? undefined,
209
- permissionMode: command.planMode ? "plan" : "acceptEdits",
210
- canUseTool,
211
- tools: [...TOOLSET],
212
- settingSources: ["user", "project", "local"],
213
- env: {
214
- ...process.env,
215
- // CLAUDE_AGENT_SDK_CLIENT_APP: SDK_CLIENT_APP,
216
- },
217
- },
218
- })
440
+ model: args.model,
441
+ serviceTier: args.serviceTier,
442
+ sessionToken: chat.sessionToken,
443
+ })
444
+ turn = await this.codexManager.startTurn({
445
+ chatId: args.chatId,
446
+ content: args.content,
447
+ model: args.model,
448
+ effort: args.effort as any,
449
+ serviceTier: args.serviceTier,
450
+ planMode: args.planMode,
451
+ onToolRequest,
452
+ })
453
+ }
219
454
 
220
455
  const active: ActiveTurn = {
221
- chatId,
222
- query: q,
456
+ chatId: args.chatId,
457
+ provider: args.provider,
458
+ turn,
459
+ model: args.model,
460
+ effort: args.effort,
461
+ serviceTier: args.serviceTier,
462
+ planMode: args.planMode,
223
463
  status: "starting",
224
464
  pendingTool: null,
465
+ postToolFollowUp: null,
225
466
  hasFinalResult: false,
226
467
  cancelRequested: false,
227
468
  cancelRecorded: false,
228
469
  }
229
- this.activeTurns.set(chatId, active)
470
+ this.activeTurns.set(args.chatId, active)
230
471
  this.onStateChange()
231
472
 
232
- void q.accountInfo()
233
- .then(async (accountInfo) => {
234
- await this.store.appendMessage(
235
- chatId!,
236
- toTranscriptEntry(JSON.stringify({ type: "system", subtype: "account_info", accountInfo }))
237
- )
238
- this.onStateChange()
239
- })
240
- .catch(() => undefined)
473
+ if (turn.getAccountInfo) {
474
+ void turn.getAccountInfo()
475
+ .then(async (accountInfo) => {
476
+ if (!accountInfo) return
477
+ await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
478
+ this.onStateChange()
479
+ })
480
+ .catch(() => undefined)
481
+ }
241
482
 
242
483
  void this.runTurn(active)
484
+ }
485
+
486
+ async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
487
+ let chatId = command.chatId
488
+
489
+ if (!chatId) {
490
+ if (!command.projectId) {
491
+ throw new Error("Missing projectId for new chat")
492
+ }
493
+ const created = await this.store.createChat(command.projectId)
494
+ chatId = created.id
495
+ }
496
+
497
+ const chat = this.store.requireChat(chatId)
498
+ const provider = this.resolveProvider(command, chat.provider)
499
+ const settings = this.getProviderSettings(provider, command)
500
+ await this.startTurnForChat({
501
+ chatId,
502
+ provider,
503
+ content: command.content,
504
+ model: settings.model,
505
+ effort: settings.effort,
506
+ serviceTier: settings.serviceTier,
507
+ planMode: settings.planMode,
508
+ appendUserPrompt: true,
509
+ })
243
510
 
244
511
  return { chatId }
245
512
  }
246
513
 
247
514
  private async runTurn(active: ActiveTurn) {
248
515
  try {
249
- for await (const sdkMessage of active.query) {
250
- const raw = JSON.stringify(sdkMessage)
251
- const maybeMessageId = "uuid" in sdkMessage && sdkMessage.uuid ? String(sdkMessage.uuid) : crypto.randomUUID()
252
-
253
- await this.store.appendMessage(active.chatId, toTranscriptEntry(raw, maybeMessageId))
254
-
255
- const sessionId = "session_id" in sdkMessage && typeof sdkMessage.session_id === "string"
256
- ? sdkMessage.session_id
257
- : null
258
- if (sessionId) {
259
- await this.store.setResumeSession(active.chatId, sessionId)
516
+ for await (const event of active.turn.stream) {
517
+ if (event.type === "session_token" && event.sessionToken) {
518
+ await this.store.setSessionToken(active.chatId, event.sessionToken)
519
+ this.onStateChange()
520
+ continue
260
521
  }
261
522
 
262
- if (sdkMessage.type === "system" && sdkMessage.subtype === "init") {
523
+ if (!event.entry) continue
524
+ await this.store.appendMessage(active.chatId, event.entry)
525
+
526
+ if (event.entry.kind === "system_init") {
263
527
  active.status = "running"
264
528
  }
265
529
 
266
- if (sdkMessage.type === "result") {
530
+ if (event.entry.kind === "result") {
267
531
  active.hasFinalResult = true
268
- if (sdkMessage.is_error) {
269
- const errorText = "errors" in sdkMessage && Array.isArray(sdkMessage.errors)
270
- ? sdkMessage.errors.join("\n")
271
- : "Turn failed"
272
- await this.store.recordTurnFailed(active.chatId, errorText)
532
+ if (event.entry.isError) {
533
+ await this.store.recordTurnFailed(active.chatId, event.entry.result || "Turn failed")
273
534
  } else if (!active.cancelRequested) {
274
535
  await this.store.recordTurnFinished(active.chatId)
275
536
  }
@@ -280,16 +541,54 @@ export class AgentCoordinator {
280
541
  } catch (error) {
281
542
  if (!active.cancelRequested) {
282
543
  const message = error instanceof Error ? error.message : String(error)
283
- await this.store.appendMessage(active.chatId, toTranscriptEntry(buildErrorPayload(message)))
544
+ await this.store.appendMessage(
545
+ active.chatId,
546
+ timestamped({
547
+ kind: "result",
548
+ subtype: "error",
549
+ isError: true,
550
+ durationMs: 0,
551
+ result: message,
552
+ })
553
+ )
284
554
  await this.store.recordTurnFailed(active.chatId, message)
285
555
  }
286
556
  } finally {
287
557
  if (active.cancelRequested && !active.cancelRecorded) {
288
558
  await this.store.recordTurnCancelled(active.chatId)
289
559
  }
290
- active.query.close()
560
+ active.turn.close()
291
561
  this.activeTurns.delete(active.chatId)
292
562
  this.onStateChange()
563
+
564
+ if (active.postToolFollowUp && !active.cancelRequested) {
565
+ try {
566
+ await this.startTurnForChat({
567
+ chatId: active.chatId,
568
+ provider: active.provider,
569
+ content: active.postToolFollowUp.content,
570
+ model: active.model,
571
+ effort: active.effort,
572
+ serviceTier: active.serviceTier,
573
+ planMode: active.postToolFollowUp.planMode,
574
+ appendUserPrompt: false,
575
+ })
576
+ } catch (error) {
577
+ const message = error instanceof Error ? error.message : String(error)
578
+ await this.store.appendMessage(
579
+ active.chatId,
580
+ timestamped({
581
+ kind: "result",
582
+ subtype: "error",
583
+ isError: true,
584
+ durationMs: 0,
585
+ result: message,
586
+ })
587
+ )
588
+ await this.store.recordTurnFailed(active.chatId, message)
589
+ this.onStateChange()
590
+ }
591
+ }
293
592
  }
294
593
  }
295
594
 
@@ -300,15 +599,15 @@ export class AgentCoordinator {
300
599
  active.cancelRequested = true
301
600
  active.pendingTool = null
302
601
 
303
- await this.store.appendMessage(chatId, toTranscriptEntry(buildCancelledPayload(), crypto.randomUUID()))
602
+ await this.store.appendMessage(chatId, timestamped({ kind: "interrupted" }))
304
603
  await this.store.recordTurnCancelled(chatId)
305
604
  active.cancelRecorded = true
306
605
  active.hasFinalResult = true
307
606
 
308
607
  try {
309
- await active.query.interrupt()
608
+ await active.turn.interrupt()
310
609
  } catch {
311
- active.query.close()
610
+ active.turn.close()
312
611
  }
313
612
 
314
613
  this.activeTurns.delete(chatId)
@@ -328,56 +627,46 @@ export class AgentCoordinator {
328
627
 
329
628
  await this.store.appendMessage(
330
629
  command.chatId,
331
- toTranscriptEntry(buildToolResultPayload(command.toolUseId, command.result), crypto.randomUUID())
630
+ timestamped({
631
+ kind: "tool_result",
632
+ toolId: command.toolUseId,
633
+ content: command.result,
634
+ })
332
635
  )
333
636
 
334
637
  active.pendingTool = null
335
638
  active.status = "running"
336
639
 
337
- if (pending.toolName === "AskUserQuestion") {
338
- const result = command.result as { questions?: unknown; answers?: unknown }
339
- pending.resolve({
340
- behavior: "allow",
341
- updatedInput: {
342
- ...(pending.input ?? {}),
343
- questions: result.questions ?? (pending.input.questions as unknown),
344
- answers: result.answers ?? result,
345
- },
346
- })
347
- this.onStateChange()
348
- return
349
- }
350
-
351
- const result = (command.result ?? {}) as {
352
- confirmed?: boolean
353
- clearContext?: boolean
354
- message?: string
355
- }
640
+ if (pending.tool.toolKind === "exit_plan_mode") {
641
+ const result = (command.result ?? {}) as {
642
+ confirmed?: boolean
643
+ clearContext?: boolean
644
+ message?: string
645
+ }
646
+ if (result.confirmed && result.clearContext) {
647
+ await this.store.setSessionToken(command.chatId, null)
648
+ await this.store.appendMessage(command.chatId, timestamped({ kind: "context_cleared" }))
649
+ }
356
650
 
357
- if (result.confirmed) {
358
- if (result.clearContext) {
359
- await this.store.setResumeSession(command.chatId, null)
360
- await this.store.appendMessage(
361
- command.chatId,
362
- toTranscriptEntry(JSON.stringify({ type: "system", subtype: "context_cleared" }), crypto.randomUUID())
363
- )
651
+ if (active.provider === "codex") {
652
+ active.postToolFollowUp = result.confirmed
653
+ ? {
654
+ content: result.message
655
+ ? `Proceed with the approved plan. Additional guidance: ${result.message}`
656
+ : "Proceed with the approved plan.",
657
+ planMode: false,
658
+ }
659
+ : {
660
+ content: result.message
661
+ ? `Revise the plan using this feedback: ${result.message}`
662
+ : "Revise the plan using this feedback.",
663
+ planMode: true,
664
+ }
364
665
  }
365
- pending.resolve({
366
- behavior: "allow",
367
- updatedInput: {
368
- ...(pending.input ?? {}),
369
- ...result,
370
- },
371
- })
372
- } else {
373
- pending.resolve({
374
- behavior: "deny",
375
- message: result.message
376
- ? `User wants to suggest edits to the plan: ${result.message}`
377
- : "User wants to suggest edits to the plan before approving.",
378
- })
379
666
  }
380
667
 
668
+ pending.resolve(command.result)
669
+
381
670
  this.onStateChange()
382
671
  }
383
672
  }