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.
- package/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- 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
|
+
}
|