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,1120 @@
1
+ import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
+ import type {
3
+ AgentProvider,
4
+ ChatAttachment,
5
+ ChatPendingToolSnapshot,
6
+ ChatUsageSnapshot,
7
+ NormalizedToolCall,
8
+ PendingToolSnapshot,
9
+ KaizenStatus,
10
+ TranscriptEntry,
11
+ UserPromptEntry,
12
+ } from "../shared/types"
13
+ import { normalizeToolCall } from "../shared/tools"
14
+ import type { ClientCommand } from "../shared/protocol"
15
+ import { EventStore } from "./event-store"
16
+ import { persistChatAttachments, resolveAttachmentPath } from "./attachments"
17
+ import { CodexAppServerManager } from "./codex-app-server"
18
+ import { CursorAcpManager } from "./cursor-acp"
19
+ import { GeminiAcpManager } from "./gemini-acp"
20
+ import { generateTitleForChat } from "./generate-title"
21
+ import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
22
+ import {
23
+ codexServiceTierFromModelOptions,
24
+ getServerProviderCatalog,
25
+ normalizeClaudeModelOptions,
26
+ normalizeCodexModelOptions,
27
+ normalizeCursorModelOptions,
28
+ normalizeGeminiModelOptions,
29
+ normalizeServerModel,
30
+ } from "./provider-catalog"
31
+ import { createClaudeRateLimitSnapshot } from "./usage/claude-usage"
32
+ import { deriveProviderUsage } from "./usage/provider-usage"
33
+ import type { ProviderUsageMap } from "../shared/types"
34
+ import { resolveClaudeApiModelId } from "../shared/types"
35
+
36
+ const CLAUDE_TOOLSET = [
37
+ "Skill",
38
+ "WebFetch",
39
+ "WebSearch",
40
+ "Task",
41
+ "TaskOutput",
42
+ "Bash",
43
+ "Glob",
44
+ "Grep",
45
+ "Read",
46
+ "Edit",
47
+ "Write",
48
+ "TodoWrite",
49
+ "KillShell",
50
+ "AskUserQuestion",
51
+ "EnterPlanMode",
52
+ "ExitPlanMode",
53
+ ] as const
54
+
55
+ interface PendingToolRequest {
56
+ toolUseId: string
57
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
58
+ resolve: (result: unknown) => void
59
+ }
60
+
61
+ interface ActiveTurn {
62
+ chatId: string
63
+ provider: AgentProvider
64
+ turn: HarnessTurn
65
+ model: string
66
+ effort?: string
67
+ serviceTier?: "fast"
68
+ fastMode?: boolean
69
+ planMode: boolean
70
+ status: KaizenStatus
71
+ pendingTool: PendingToolRequest | null
72
+ postToolFollowUp: { content: string; planMode: boolean } | null
73
+ hasFinalResult: boolean
74
+ cancelRequested: boolean
75
+ cancelRecorded: boolean
76
+ }
77
+
78
+ interface AgentCoordinatorArgs {
79
+ store: EventStore
80
+ onStateChange: () => void
81
+ attachmentsDir: string
82
+ codexManager?: CodexAppServerManager
83
+ cursorManager?: CursorAcpManager
84
+ geminiManager?: GeminiAcpManager
85
+ generateTitle?: (messageContent: string, cwd: string) => Promise<string | null>
86
+ }
87
+
88
+ type RecoverablePendingTool = NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
89
+
90
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
91
+ entry: T,
92
+ createdAt = Date.now()
93
+ ): TranscriptEntry {
94
+ return {
95
+ _id: crypto.randomUUID(),
96
+ createdAt,
97
+ ...entry,
98
+ } as TranscriptEntry
99
+ }
100
+
101
+ function stringFromUnknown(value: unknown) {
102
+ if (typeof value === "string") return value
103
+ try {
104
+ return JSON.stringify(value, null, 2)
105
+ } catch {
106
+ return String(value)
107
+ }
108
+ }
109
+
110
+ function discardedToolResult(
111
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
112
+ ) {
113
+ if (tool.toolKind === "ask_user_question") {
114
+ return {
115
+ discarded: true,
116
+ answers: {},
117
+ }
118
+ }
119
+
120
+ return {
121
+ discarded: true,
122
+ }
123
+ }
124
+
125
+ function asRecord(value: unknown): Record<string, unknown> | null {
126
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
127
+ return value as Record<string, unknown>
128
+ }
129
+
130
+ function shouldUseSyntheticPlanFollowUp(
131
+ provider: AgentProvider,
132
+ tool: NormalizedToolCall & { toolKind: "exit_plan_mode" }
133
+ ) {
134
+ if (provider === "codex") return true
135
+ if (provider !== "cursor") return false
136
+ return asRecord(tool.rawInput)?.source === "cursor/create_plan"
137
+ }
138
+
139
+ function findLatestRecoverablePendingTool(args: {
140
+ messages: TranscriptEntry[]
141
+ planMode: boolean
142
+ }): RecoverablePendingTool | null {
143
+ const completedToolIds = new Set(
144
+ args.messages
145
+ .filter((entry): entry is Extract<TranscriptEntry, { kind: "tool_result" }> => entry.kind === "tool_result")
146
+ .map((entry) => entry.toolId)
147
+ )
148
+
149
+ for (let index = args.messages.length - 1; index >= 0; index -= 1) {
150
+ const entry = args.messages[index]
151
+ if (entry.kind !== "tool_call") continue
152
+ const tool = entry.tool
153
+ const isRecoverableAskUserQuestion = tool.toolKind === "ask_user_question"
154
+ const isRecoverableExitPlan = args.planMode && tool.toolKind === "exit_plan_mode"
155
+ if (!isRecoverableAskUserQuestion && !isRecoverableExitPlan) continue
156
+ if (completedToolIds.has(tool.toolId)) return null
157
+ return tool
158
+ }
159
+
160
+ return null
161
+ }
162
+
163
+ function formatRecoveredAskUserQuestionFollowUp(
164
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" },
165
+ result: unknown
166
+ ) {
167
+ const record = asRecord(result)
168
+ const answersValue = asRecord(record?.answers) ?? record ?? {}
169
+ const lines = tool.input.questions.map((question) => {
170
+ const rawAnswer = (question.id ? answersValue[question.id] : undefined) ?? answersValue[question.question]
171
+ const answers = Array.isArray(rawAnswer)
172
+ ? rawAnswer.map((entry) => String(entry)).filter(Boolean)
173
+ : rawAnswer == null || rawAnswer === ""
174
+ ? []
175
+ : [String(rawAnswer)]
176
+ const label = question.question.trim() || question.id || "Question"
177
+ return `- ${label}: ${answers.length > 0 ? answers.join(", ") : "No response"}`
178
+ })
179
+
180
+ return [
181
+ "The app restarted while you were waiting for user input.",
182
+ "Resume from that point using the recovered answers below.",
183
+ "",
184
+ "Recovered user answers:",
185
+ ...lines,
186
+ ].join("\n")
187
+ }
188
+
189
+ function planModeFollowUp(result: { confirmed?: boolean; message?: string }) {
190
+ if (result.confirmed) {
191
+ return {
192
+ content: result.message
193
+ ? `Proceed with the approved plan. Additional guidance: ${result.message}`
194
+ : "Proceed with the approved plan.",
195
+ planMode: false,
196
+ }
197
+ }
198
+
199
+ return {
200
+ content: result.message
201
+ ? `Revise the plan using this feedback: ${result.message}`
202
+ : "Revise the plan using this feedback.",
203
+ planMode: true,
204
+ }
205
+ }
206
+
207
+ export function normalizeClaudeStreamMessage(message: any): TranscriptEntry[] {
208
+ const debugRaw = JSON.stringify(message)
209
+ const messageId = typeof message.uuid === "string" ? message.uuid : undefined
210
+
211
+ if (message.type === "system" && message.subtype === "init") {
212
+ return [
213
+ timestamped({
214
+ kind: "system_init",
215
+ messageId,
216
+ provider: "claude",
217
+ model: typeof message.model === "string" ? message.model : "unknown",
218
+ tools: Array.isArray(message.tools) ? message.tools : [],
219
+ agents: Array.isArray(message.agents) ? message.agents : [],
220
+ slashCommands: Array.isArray(message.slash_commands)
221
+ ? message.slash_commands.filter((entry: string) => !entry.startsWith("._"))
222
+ : [],
223
+ mcpServers: Array.isArray(message.mcp_servers) ? message.mcp_servers : [],
224
+ debugRaw,
225
+ }),
226
+ ]
227
+ }
228
+
229
+ if (message.type === "assistant" && Array.isArray(message.message?.content)) {
230
+ const entries: TranscriptEntry[] = []
231
+ for (const content of message.message.content) {
232
+ if (content.type === "text" && typeof content.text === "string") {
233
+ entries.push(timestamped({
234
+ kind: "assistant_text",
235
+ messageId,
236
+ text: content.text,
237
+ debugRaw,
238
+ }))
239
+ }
240
+ if (content.type === "tool_use" && typeof content.name === "string" && typeof content.id === "string") {
241
+ entries.push(timestamped({
242
+ kind: "tool_call",
243
+ messageId,
244
+ tool: normalizeToolCall({
245
+ toolName: content.name,
246
+ toolId: content.id,
247
+ input: (content.input ?? {}) as Record<string, unknown>,
248
+ }),
249
+ debugRaw,
250
+ }))
251
+ }
252
+ }
253
+ return entries
254
+ }
255
+
256
+ if (message.type === "user" && Array.isArray(message.message?.content)) {
257
+ const entries: TranscriptEntry[] = []
258
+ for (const content of message.message.content) {
259
+ if (content.type === "tool_result" && typeof content.tool_use_id === "string") {
260
+ entries.push(timestamped({
261
+ kind: "tool_result",
262
+ messageId,
263
+ toolId: content.tool_use_id,
264
+ content: content.content,
265
+ isError: Boolean(content.is_error),
266
+ debugRaw,
267
+ }))
268
+ }
269
+ if (message.message.role === "user" && typeof message.message.content === "string") {
270
+ entries.push(timestamped({
271
+ kind: "compact_summary",
272
+ messageId,
273
+ summary: message.message.content,
274
+ debugRaw,
275
+ }))
276
+ }
277
+ }
278
+ return entries
279
+ }
280
+
281
+ if (message.type === "result") {
282
+ if (message.subtype === "cancelled") {
283
+ return [timestamped({ kind: "interrupted", messageId, debugRaw })]
284
+ }
285
+ return [
286
+ timestamped({
287
+ kind: "result",
288
+ messageId,
289
+ subtype: message.is_error ? "error" : "success",
290
+ isError: Boolean(message.is_error),
291
+ durationMs: typeof message.duration_ms === "number" ? message.duration_ms : 0,
292
+ result: typeof message.result === "string" ? message.result : stringFromUnknown(message.result),
293
+ costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined,
294
+ debugRaw,
295
+ }),
296
+ ]
297
+ }
298
+
299
+ if (message.type === "system" && message.subtype === "status" && typeof message.status === "string") {
300
+ return [timestamped({ kind: "status", messageId, status: message.status, debugRaw })]
301
+ }
302
+
303
+ if (message.type === "system" && message.subtype === "compact_boundary") {
304
+ return [timestamped({ kind: "compact_boundary", messageId, debugRaw })]
305
+ }
306
+
307
+ if (message.type === "system" && message.subtype === "context_cleared") {
308
+ return [timestamped({ kind: "context_cleared", messageId, debugRaw })]
309
+ }
310
+
311
+ if (
312
+ message.type === "user" &&
313
+ message.message?.role === "user" &&
314
+ typeof message.message.content === "string" &&
315
+ message.message.content.startsWith("This session is being continued")
316
+ ) {
317
+ return [timestamped({ kind: "compact_summary", messageId, summary: message.message.content, debugRaw })]
318
+ }
319
+
320
+ return []
321
+ }
322
+
323
+ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent> {
324
+ for await (const sdkMessage of q as AsyncIterable<any>) {
325
+ const sessionToken = typeof sdkMessage.session_id === "string" ? sdkMessage.session_id : null
326
+ if (sessionToken) {
327
+ yield { type: "session_token", sessionToken }
328
+ }
329
+
330
+ if (sdkMessage.type === "rate_limit_event") {
331
+ const rateLimitInfo = sdkMessage.rate_limit_info
332
+ const rawUtilization = typeof rateLimitInfo?.utilization === "number" ? rateLimitInfo.utilization : null
333
+ const usage = createClaudeRateLimitSnapshot(
334
+ rawUtilization !== null ? rawUtilization * 100 : null,
335
+ typeof rateLimitInfo?.resetsAt === "number" ? rateLimitInfo.resetsAt * 1000 : null
336
+ )
337
+ if (usage) {
338
+ yield { type: "usage", usage }
339
+ }
340
+ }
341
+
342
+ for (const entry of normalizeClaudeStreamMessage(sdkMessage)) {
343
+ yield { type: "transcript", entry }
344
+ }
345
+ }
346
+ }
347
+
348
+ async function createClaudePrompt(
349
+ content: string,
350
+ attachmentFiles: Array<{ filePath: string; mimeType: string }>,
351
+ sessionToken: string | null
352
+ ): Promise<string | AsyncIterable<SDKUserMessage>> {
353
+ if (attachmentFiles.length === 0) {
354
+ return content
355
+ }
356
+
357
+ const blocks: Array<Record<string, unknown>> = []
358
+ for (const attachment of attachmentFiles) {
359
+ const bytes = Buffer.from(await Bun.file(attachment.filePath).arrayBuffer())
360
+ blocks.push({
361
+ type: "image",
362
+ source: {
363
+ type: "base64",
364
+ media_type: attachment.mimeType,
365
+ data: bytes.toString("base64"),
366
+ },
367
+ })
368
+ }
369
+
370
+ if (content) {
371
+ blocks.push({
372
+ type: "text",
373
+ text: content,
374
+ })
375
+ }
376
+
377
+ return (async function* () {
378
+ yield {
379
+ type: "user",
380
+ session_id: sessionToken ?? crypto.randomUUID(),
381
+ parent_tool_use_id: null,
382
+ message: {
383
+ role: "user",
384
+ content: blocks,
385
+ } as SDKUserMessage["message"],
386
+ }
387
+ })()
388
+ }
389
+
390
+ async function startClaudeTurn(args: {
391
+ content: string
392
+ attachmentFiles?: Array<{ filePath: string; mimeType: string }>
393
+ localPath: string
394
+ model: string
395
+ effort?: string
396
+ planMode: boolean
397
+ sessionToken: string | null
398
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
399
+ }): Promise<HarnessTurn> {
400
+ const canUseTool: CanUseTool = async (toolName, input, options) => {
401
+ if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
402
+ return {
403
+ behavior: "allow",
404
+ updatedInput: input,
405
+ }
406
+ }
407
+
408
+ const tool = normalizeToolCall({
409
+ toolName,
410
+ toolId: options.toolUseID,
411
+ input: (input ?? {}) as Record<string, unknown>,
412
+ })
413
+
414
+ if (tool.toolKind !== "ask_user_question" && tool.toolKind !== "exit_plan_mode") {
415
+ return {
416
+ behavior: "deny",
417
+ message: "Unsupported tool request",
418
+ }
419
+ }
420
+
421
+ const result = await args.onToolRequest({ tool })
422
+
423
+ if (tool.toolKind === "ask_user_question") {
424
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
425
+ return {
426
+ behavior: "allow",
427
+ updatedInput: {
428
+ ...(tool.rawInput ?? {}),
429
+ questions: record.questions ?? tool.input.questions,
430
+ answers: record.answers ?? result,
431
+ },
432
+ } satisfies PermissionResult
433
+ }
434
+
435
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
436
+ const confirmed = Boolean(record.confirmed)
437
+ if (confirmed) {
438
+ return {
439
+ behavior: "allow",
440
+ updatedInput: {
441
+ ...(tool.rawInput ?? {}),
442
+ ...record,
443
+ },
444
+ } satisfies PermissionResult
445
+ }
446
+
447
+ return {
448
+ behavior: "deny",
449
+ message: typeof record.message === "string"
450
+ ? `User wants to suggest edits to the plan: ${record.message}`
451
+ : "User wants to suggest edits to the plan before approving.",
452
+ } satisfies PermissionResult
453
+ }
454
+
455
+ const q = query({
456
+ prompt: await createClaudePrompt(args.content, args.attachmentFiles ?? [], args.sessionToken),
457
+ options: {
458
+ cwd: args.localPath,
459
+ model: args.model,
460
+ effort: args.effort as "low" | "medium" | "high" | "max" | undefined,
461
+ resume: args.sessionToken ?? undefined,
462
+ permissionMode: args.planMode ? "plan" : "acceptEdits",
463
+ canUseTool,
464
+ tools: [...CLAUDE_TOOLSET],
465
+ settingSources: ["user", "project", "local"],
466
+ env: (() => { const { CLAUDECODE: _, ...env } = process.env; return env })(),
467
+ },
468
+ })
469
+
470
+ return {
471
+ provider: "claude",
472
+ stream: createClaudeHarnessStream(q),
473
+ getAccountInfo: async () => {
474
+ try {
475
+ return await q.accountInfo()
476
+ } catch {
477
+ return null
478
+ }
479
+ },
480
+ interrupt: async () => {
481
+ await q.interrupt()
482
+ },
483
+ close: () => {
484
+ q.close()
485
+ },
486
+ }
487
+ }
488
+
489
+ export class AgentCoordinator {
490
+ private readonly store: EventStore
491
+ private readonly onStateChange: () => void
492
+ private readonly attachmentsDir: string
493
+ private readonly codexManager: CodexAppServerManager
494
+ private readonly cursorManager: CursorAcpManager
495
+ private readonly geminiManager: GeminiAcpManager
496
+ private readonly generateTitle: (messageContent: string, cwd: string) => Promise<string | null>
497
+ readonly activeTurns = new Map<string, ActiveTurn>()
498
+ readonly liveUsage = new Map<string, ChatUsageSnapshot>()
499
+
500
+ constructor(args: AgentCoordinatorArgs) {
501
+ this.store = args.store
502
+ this.onStateChange = args.onStateChange
503
+ this.attachmentsDir = args.attachmentsDir
504
+ this.codexManager = args.codexManager ?? new CodexAppServerManager()
505
+ this.cursorManager = args.cursorManager ?? new CursorAcpManager()
506
+ this.geminiManager = args.geminiManager ?? new GeminiAcpManager()
507
+ this.generateTitle = args.generateTitle ?? generateTitleForChat
508
+ }
509
+
510
+ getActiveStatuses() {
511
+ const statuses = new Map<string, KaizenStatus>()
512
+ for (const [chatId, turn] of this.activeTurns.entries()) {
513
+ statuses.set(chatId, turn.status)
514
+ }
515
+
516
+ for (const chat of this.store.state.chatsById.values()) {
517
+ if (chat.deletedAt || statuses.has(chat.id)) continue
518
+ if (this.getRecoveredPendingTool(chat.id)) {
519
+ statuses.set(chat.id, "waiting_for_user")
520
+ }
521
+ }
522
+
523
+ return statuses
524
+ }
525
+
526
+ getPendingTool(chatId: string): PendingToolSnapshot | null {
527
+ const pending = this.activeTurns.get(chatId)?.pendingTool
528
+ if (pending) {
529
+ return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
530
+ }
531
+
532
+ return this.getRecoveredPendingTool(chatId)
533
+ }
534
+
535
+ getLiveUsage(chatId: string) {
536
+ return this.liveUsage.get(chatId) ?? null
537
+ }
538
+
539
+ getProviderUsage(): ProviderUsageMap {
540
+ return deriveProviderUsage(this.liveUsage, this.store)
541
+ }
542
+
543
+ getChatPendingTool(chatId: string): ChatPendingToolSnapshot | null {
544
+ const pending = this.activeTurns.get(chatId)?.pendingTool
545
+ if (pending) {
546
+ return {
547
+ toolUseId: pending.toolUseId,
548
+ toolKind: pending.tool.toolKind,
549
+ source: "active",
550
+ }
551
+ }
552
+
553
+ const recovered = this.getRecoveredPendingTool(chatId)
554
+ return recovered
555
+ ? {
556
+ ...recovered,
557
+ source: "recovered",
558
+ }
559
+ : null
560
+ }
561
+
562
+ private getRecoveredPendingToolRequest(chatId: string): RecoverablePendingTool | null {
563
+ if (this.activeTurns.has(chatId)) return null
564
+
565
+ const chat = this.store.getChat(chatId)
566
+ if (!chat || !chat.provider) return null
567
+
568
+ const pendingTool = findLatestRecoverablePendingTool({
569
+ messages: this.store.getMessages(chatId),
570
+ planMode: chat.planMode,
571
+ })
572
+ if (!pendingTool) return null
573
+
574
+ return pendingTool
575
+ }
576
+
577
+ private getRecoveredPendingTool(chatId: string): PendingToolSnapshot | null {
578
+ const pendingTool = this.getRecoveredPendingToolRequest(chatId)
579
+ if (!pendingTool) return null
580
+
581
+ return {
582
+ toolUseId: pendingTool.toolId,
583
+ toolKind: pendingTool.toolKind,
584
+ }
585
+ }
586
+
587
+ private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
588
+ if (currentProvider) return currentProvider
589
+ return command.provider ?? "claude"
590
+ }
591
+
592
+ private getProviderSettings(provider: AgentProvider, command: Extract<ClientCommand, { type: "chat.send" }>) {
593
+ const catalog = getServerProviderCatalog(provider)
594
+ if (provider === "claude") {
595
+ const model = normalizeServerModel(provider, command.model)
596
+ const modelOptions = normalizeClaudeModelOptions(model, command.modelOptions, command.effort)
597
+ return {
598
+ model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
599
+ effort: modelOptions.reasoningEffort,
600
+ serviceTier: undefined,
601
+ fastMode: undefined,
602
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
603
+ }
604
+ }
605
+
606
+ if (provider === "gemini") {
607
+ const modelOptions = normalizeGeminiModelOptions(command.modelOptions)
608
+ return {
609
+ model: normalizeServerModel(provider, command.model),
610
+ effort: modelOptions.thinkingMode,
611
+ serviceTier: undefined,
612
+ fastMode: undefined,
613
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
614
+ }
615
+ }
616
+
617
+ if (provider === "cursor") {
618
+ normalizeCursorModelOptions(command.modelOptions)
619
+ return {
620
+ model: normalizeServerModel(provider, command.model),
621
+ effort: undefined,
622
+ serviceTier: undefined,
623
+ fastMode: undefined,
624
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
625
+ }
626
+ }
627
+
628
+ const modelOptions = normalizeCodexModelOptions(command.modelOptions, command.effort)
629
+ return {
630
+ model: normalizeServerModel(provider, command.model),
631
+ effort: modelOptions.reasoningEffort,
632
+ serviceTier: codexServiceTierFromModelOptions(modelOptions),
633
+ fastMode: undefined,
634
+ planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
635
+ }
636
+ }
637
+
638
+ private async startTurnForChat(args: {
639
+ chatId: string
640
+ provider: AgentProvider
641
+ content: string
642
+ attachments?: ChatAttachment[]
643
+ model: string
644
+ effort?: string
645
+ serviceTier?: "fast"
646
+ fastMode?: boolean
647
+ planMode: boolean
648
+ appendUserPrompt: UserPromptEntry | null
649
+ }) {
650
+ const chat = this.store.requireChat(args.chatId)
651
+ if (this.activeTurns.has(args.chatId)) {
652
+ throw new Error("Chat is already running")
653
+ }
654
+
655
+ if (!chat.provider) {
656
+ await this.store.setChatProvider(args.chatId, args.provider)
657
+ }
658
+ await this.store.setPlanMode(args.chatId, args.planMode)
659
+
660
+ const existingMessages = this.store.getMessages(args.chatId)
661
+ const shouldGenerateTitle = Boolean(args.appendUserPrompt) && chat.title === "New Chat" && existingMessages.length === 0
662
+
663
+ if (args.appendUserPrompt) {
664
+ await this.store.appendMessage(args.chatId, args.appendUserPrompt)
665
+ }
666
+ await this.store.recordTurnStarted(args.chatId)
667
+
668
+ const project = this.store.getProject(chat.projectId)
669
+ if (!project) {
670
+ throw new Error("Project not found")
671
+ }
672
+
673
+ const attachmentFiles = (args.attachments ?? []).map((attachment) => {
674
+ const filePath = resolveAttachmentPath(this.attachmentsDir, attachment.relativePath)
675
+ if (!filePath) {
676
+ throw new Error(`Failed to resolve attachment '${attachment.name}'.`)
677
+ }
678
+ return {
679
+ filePath,
680
+ mimeType: attachment.mimeType,
681
+ }
682
+ })
683
+
684
+ if (shouldGenerateTitle) {
685
+ void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
686
+ }
687
+
688
+ const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
689
+ const active = this.activeTurns.get(args.chatId)
690
+ if (!active) {
691
+ throw new Error("Chat turn ended unexpectedly")
692
+ }
693
+
694
+ active.status = "waiting_for_user"
695
+ this.onStateChange()
696
+
697
+ return await new Promise<unknown>((resolve) => {
698
+ active.pendingTool = {
699
+ toolUseId: request.tool.toolId,
700
+ tool: request.tool,
701
+ resolve,
702
+ }
703
+ })
704
+ }
705
+
706
+ let turn: HarnessTurn
707
+ if (args.provider === "claude") {
708
+ turn = await startClaudeTurn({
709
+ content: args.content,
710
+ attachmentFiles,
711
+ localPath: project.localPath,
712
+ model: args.model,
713
+ effort: args.effort,
714
+ planMode: args.planMode,
715
+ sessionToken: chat.sessionToken,
716
+ onToolRequest,
717
+ })
718
+ } else if (args.provider === "gemini") {
719
+ turn = await this.geminiManager.startTurn({
720
+ chatId: args.chatId,
721
+ content: args.content,
722
+ localPath: project.localPath,
723
+ model: args.model,
724
+ thinkingMode: (args.effort as "off" | "standard" | "high" | undefined) ?? "standard",
725
+ planMode: args.planMode,
726
+ sessionToken: chat.sessionToken,
727
+ onToolRequest,
728
+ })
729
+ } else if (args.provider === "cursor") {
730
+ turn = await this.cursorManager.startTurn({
731
+ chatId: args.chatId,
732
+ content: args.content,
733
+ localPath: project.localPath,
734
+ model: args.model,
735
+ planMode: args.planMode,
736
+ sessionToken: chat.sessionToken,
737
+ onToolRequest,
738
+ })
739
+ } else {
740
+ await this.codexManager.startSession({
741
+ chatId: args.chatId,
742
+ cwd: project.localPath,
743
+ model: args.model,
744
+ serviceTier: args.serviceTier,
745
+ sessionToken: chat.sessionToken,
746
+ })
747
+ turn = await this.codexManager.startTurn({
748
+ chatId: args.chatId,
749
+ content: args.content,
750
+ attachments: attachmentFiles,
751
+ model: args.model,
752
+ effort: args.effort as any,
753
+ serviceTier: args.serviceTier,
754
+ planMode: args.planMode,
755
+ onToolRequest,
756
+ })
757
+ }
758
+
759
+ const active: ActiveTurn = {
760
+ chatId: args.chatId,
761
+ provider: args.provider,
762
+ turn,
763
+ model: args.model,
764
+ effort: args.effort,
765
+ serviceTier: args.serviceTier,
766
+ fastMode: args.fastMode,
767
+ planMode: args.planMode,
768
+ status: "starting",
769
+ pendingTool: null,
770
+ postToolFollowUp: null,
771
+ hasFinalResult: false,
772
+ cancelRequested: false,
773
+ cancelRecorded: false,
774
+ }
775
+ this.activeTurns.set(args.chatId, active)
776
+ this.onStateChange()
777
+
778
+ if (turn.getAccountInfo) {
779
+ void turn.getAccountInfo()
780
+ .then(async (accountInfo) => {
781
+ if (!accountInfo) return
782
+ await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
783
+ this.onStateChange()
784
+ })
785
+ .catch(() => undefined)
786
+ }
787
+
788
+ void this.runTurn(active)
789
+ }
790
+
791
+ async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
792
+ let chatId = command.chatId
793
+
794
+ if (!chatId) {
795
+ if (!command.projectId) {
796
+ throw new Error("Missing projectId for new chat")
797
+ }
798
+ const created = await this.store.createChat(command.projectId)
799
+ chatId = created.id
800
+ }
801
+
802
+ const chat = this.store.requireChat(chatId)
803
+ const provider = this.resolveProvider(command, chat.provider)
804
+ const settings = this.getProviderSettings(provider, command)
805
+ const text = command.message.text.trim()
806
+ const userPrompt = timestamped({
807
+ kind: "user_prompt",
808
+ content: text,
809
+ }) as UserPromptEntry
810
+ const attachments = await persistChatAttachments({
811
+ attachmentsDir: this.attachmentsDir,
812
+ chatId,
813
+ messageEntry: userPrompt,
814
+ uploads: command.message.attachments,
815
+ })
816
+ userPrompt.attachments = attachments
817
+
818
+ if (!text && !attachments?.length) {
819
+ throw new Error("Message must include text or image attachments")
820
+ }
821
+
822
+ await this.startTurnForChat({
823
+ chatId,
824
+ provider,
825
+ content: text,
826
+ attachments,
827
+ model: settings.model,
828
+ effort: settings.effort,
829
+ serviceTier: settings.serviceTier,
830
+ fastMode: settings.fastMode,
831
+ planMode: settings.planMode,
832
+ appendUserPrompt: userPrompt,
833
+ })
834
+
835
+ return { chatId }
836
+ }
837
+
838
+ private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
839
+ try {
840
+ const title = messageContent.trim() ? await this.generateTitle(messageContent, cwd) : "Image request"
841
+ if (!title) return
842
+
843
+ const chat = this.store.requireChat(chatId)
844
+ if (chat.title !== "New Chat") return
845
+
846
+ await this.store.renameChat(chatId, title)
847
+ this.onStateChange()
848
+ } catch {
849
+ // Ignore background title generation failures.
850
+ }
851
+ }
852
+
853
+ private async runTurn(active: ActiveTurn) {
854
+ try {
855
+ for await (const event of active.turn.stream) {
856
+ if (event.type === "session_token" && event.sessionToken) {
857
+ await this.store.setSessionToken(active.chatId, event.sessionToken)
858
+ this.onStateChange()
859
+ continue
860
+ }
861
+
862
+ if (event.type === "usage" && event.usage) {
863
+ this.liveUsage.set(active.chatId, event.usage)
864
+ this.onStateChange()
865
+ continue
866
+ }
867
+
868
+ if (!event.entry) continue
869
+ if (active.hasFinalResult && event.entry.kind === "result") {
870
+ continue
871
+ }
872
+ await this.store.appendMessage(active.chatId, event.entry)
873
+
874
+ if (event.entry.kind === "system_init") {
875
+ active.status = "running"
876
+ }
877
+
878
+ if (event.entry.kind === "result") {
879
+ active.hasFinalResult = true
880
+ if (event.entry.isError) {
881
+ await this.store.recordTurnFailed(active.chatId, event.entry.result || "Turn failed")
882
+ } else if (!active.cancelRequested) {
883
+ await this.store.recordTurnFinished(active.chatId)
884
+ }
885
+ }
886
+
887
+ this.onStateChange()
888
+ }
889
+ } catch (error) {
890
+ if (!active.cancelRequested && !active.hasFinalResult) {
891
+ const message = error instanceof Error ? error.message : String(error)
892
+ await this.store.appendMessage(
893
+ active.chatId,
894
+ timestamped({
895
+ kind: "result",
896
+ subtype: "error",
897
+ isError: true,
898
+ durationMs: 0,
899
+ result: message,
900
+ })
901
+ )
902
+ await this.store.recordTurnFailed(active.chatId, message)
903
+ }
904
+ } finally {
905
+ if (active.cancelRequested && !active.cancelRecorded) {
906
+ await this.store.recordTurnCancelled(active.chatId)
907
+ }
908
+ active.turn.close()
909
+ this.activeTurns.delete(active.chatId)
910
+ this.onStateChange()
911
+
912
+ if (active.postToolFollowUp && !active.cancelRequested) {
913
+ try {
914
+ await this.startTurnForChat({
915
+ chatId: active.chatId,
916
+ provider: active.provider,
917
+ content: active.postToolFollowUp.content,
918
+ model: active.model,
919
+ effort: active.effort,
920
+ serviceTier: active.serviceTier,
921
+ fastMode: active.fastMode,
922
+ planMode: active.postToolFollowUp.planMode,
923
+ appendUserPrompt: null,
924
+ })
925
+ } catch (error) {
926
+ const message = error instanceof Error ? error.message : String(error)
927
+ await this.store.appendMessage(
928
+ active.chatId,
929
+ timestamped({
930
+ kind: "result",
931
+ subtype: "error",
932
+ isError: true,
933
+ durationMs: 0,
934
+ result: message,
935
+ })
936
+ )
937
+ await this.store.recordTurnFailed(active.chatId, message)
938
+ this.onStateChange()
939
+ }
940
+ }
941
+ }
942
+ }
943
+
944
+ async cancel(chatId: string) {
945
+ const active = this.activeTurns.get(chatId)
946
+ if (!active) return
947
+
948
+ active.cancelRequested = true
949
+
950
+ const pendingTool = active.pendingTool
951
+ active.pendingTool = null
952
+
953
+ if (pendingTool) {
954
+ const result = discardedToolResult(pendingTool.tool)
955
+ await this.store.appendMessage(
956
+ chatId,
957
+ timestamped({
958
+ kind: "tool_result",
959
+ toolId: pendingTool.toolUseId,
960
+ content: result,
961
+ })
962
+ )
963
+ if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
964
+ pendingTool.resolve(result)
965
+ }
966
+ }
967
+
968
+ await this.store.appendMessage(chatId, timestamped({ kind: "interrupted" }))
969
+ await this.store.recordTurnCancelled(chatId)
970
+ active.cancelRecorded = true
971
+ active.hasFinalResult = true
972
+
973
+ try {
974
+ await active.turn.interrupt()
975
+ } catch {
976
+ active.turn.close()
977
+ }
978
+
979
+ this.activeTurns.delete(chatId)
980
+ this.onStateChange()
981
+ }
982
+
983
+ async shutdown(chatId: string) {
984
+ const active = this.activeTurns.get(chatId)
985
+ if (!active) return
986
+
987
+ const pendingTool = active.pendingTool
988
+ const shouldPreservePendingTool =
989
+ pendingTool?.tool.toolKind === "ask_user_question"
990
+ || pendingTool?.tool.toolKind === "exit_plan_mode"
991
+
992
+ if (!shouldPreservePendingTool) {
993
+ await this.cancel(chatId)
994
+ return
995
+ }
996
+
997
+ active.cancelRequested = true
998
+ active.cancelRecorded = true
999
+ active.hasFinalResult = true
1000
+ active.pendingTool = null
1001
+ active.postToolFollowUp = null
1002
+ active.turn.close()
1003
+ this.activeTurns.delete(chatId)
1004
+ this.onStateChange()
1005
+ }
1006
+
1007
+ async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
1008
+ const active = this.activeTurns.get(command.chatId)
1009
+ if (!active || !active.pendingTool) {
1010
+ const recoveredPending = this.getRecoveredPendingToolRequest(command.chatId)
1011
+ const chat = this.store.getChat(command.chatId)
1012
+ if (!recoveredPending || !chat || !chat.provider) {
1013
+ throw new Error("No pending tool request")
1014
+ }
1015
+
1016
+ if (recoveredPending.toolId !== command.toolUseId) {
1017
+ throw new Error("Tool response does not match active request")
1018
+ }
1019
+
1020
+ await this.store.appendMessage(
1021
+ command.chatId,
1022
+ timestamped({
1023
+ kind: "tool_result",
1024
+ toolId: command.toolUseId,
1025
+ content: command.result,
1026
+ })
1027
+ )
1028
+
1029
+ const settings = this.getProviderSettings(chat.provider, {
1030
+ type: "chat.send",
1031
+ chatId: command.chatId,
1032
+ message: { text: "" },
1033
+ planMode: chat.planMode,
1034
+ })
1035
+
1036
+ if (recoveredPending.toolKind === "ask_user_question") {
1037
+ await this.startTurnForChat({
1038
+ chatId: command.chatId,
1039
+ provider: chat.provider,
1040
+ content: formatRecoveredAskUserQuestionFollowUp(recoveredPending, command.result),
1041
+ model: settings.model,
1042
+ effort: settings.effort,
1043
+ serviceTier: settings.serviceTier,
1044
+ fastMode: settings.fastMode,
1045
+ planMode: chat.planMode,
1046
+ appendUserPrompt: null,
1047
+ })
1048
+
1049
+ this.onStateChange()
1050
+ return
1051
+ }
1052
+
1053
+ const result = (command.result ?? {}) as {
1054
+ confirmed?: boolean
1055
+ clearContext?: boolean
1056
+ message?: string
1057
+ }
1058
+ if (result.confirmed && result.clearContext) {
1059
+ this.liveUsage.delete(command.chatId)
1060
+ await this.store.setSessionToken(command.chatId, null)
1061
+ await this.store.appendMessage(command.chatId, timestamped({ kind: "context_cleared" }))
1062
+ }
1063
+
1064
+ const followUp = planModeFollowUp(result)
1065
+
1066
+ await this.startTurnForChat({
1067
+ chatId: command.chatId,
1068
+ provider: chat.provider,
1069
+ content: followUp.content,
1070
+ model: settings.model,
1071
+ effort: settings.effort,
1072
+ serviceTier: settings.serviceTier,
1073
+ fastMode: settings.fastMode,
1074
+ planMode: followUp.planMode,
1075
+ appendUserPrompt: null,
1076
+ })
1077
+
1078
+ this.onStateChange()
1079
+ return
1080
+ }
1081
+
1082
+ const pending = active.pendingTool
1083
+ if (pending.toolUseId !== command.toolUseId) {
1084
+ throw new Error("Tool response does not match active request")
1085
+ }
1086
+
1087
+ await this.store.appendMessage(
1088
+ command.chatId,
1089
+ timestamped({
1090
+ kind: "tool_result",
1091
+ toolId: command.toolUseId,
1092
+ content: command.result,
1093
+ })
1094
+ )
1095
+
1096
+ active.pendingTool = null
1097
+ active.status = "running"
1098
+
1099
+ if (pending.tool.toolKind === "exit_plan_mode") {
1100
+ const result = (command.result ?? {}) as {
1101
+ confirmed?: boolean
1102
+ clearContext?: boolean
1103
+ message?: string
1104
+ }
1105
+ if (result.confirmed && result.clearContext) {
1106
+ this.liveUsage.delete(command.chatId)
1107
+ await this.store.setSessionToken(command.chatId, null)
1108
+ await this.store.appendMessage(command.chatId, timestamped({ kind: "context_cleared" }))
1109
+ }
1110
+
1111
+ if (shouldUseSyntheticPlanFollowUp(active.provider, pending.tool)) {
1112
+ active.postToolFollowUp = planModeFollowUp(result)
1113
+ }
1114
+ }
1115
+
1116
+ pending.resolve(command.result)
1117
+
1118
+ this.onStateChange()
1119
+ }
1120
+ }