kanna-code 0.1.4 → 0.3.0

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