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