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