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,525 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { writeFileSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { createInterface } from "node:readline"
|
|
7
|
+
import type { TranscriptEntry } from "../shared/types"
|
|
8
|
+
import { normalizeToolCall } from "../shared/tools"
|
|
9
|
+
import type { HarnessEvent, HarnessTurn } from "./harness-types"
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface StartGeminiTurnArgs {
|
|
16
|
+
content: string
|
|
17
|
+
localPath: string
|
|
18
|
+
model: string
|
|
19
|
+
sessionToken: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shape of each JSONL line emitted by `gemini --output-format stream-json`. */
|
|
23
|
+
interface GeminiStreamEvent {
|
|
24
|
+
type: "init" | "message" | "tool_use" | "tool_result" | "error" | "result"
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GEMINI_STDIO_NOISE = new Set([
|
|
29
|
+
"YOLO mode is enabled. All tool calls will be automatically approved.",
|
|
30
|
+
"Loaded cached credentials.",
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
38
|
+
entry: T,
|
|
39
|
+
createdAt = Date.now(),
|
|
40
|
+
): TranscriptEntry {
|
|
41
|
+
return {
|
|
42
|
+
_id: randomUUID(),
|
|
43
|
+
createdAt,
|
|
44
|
+
...entry,
|
|
45
|
+
} as TranscriptEntry
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJsonLine(line: string): GeminiStreamEvent | null {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(line)
|
|
51
|
+
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
52
|
+
return parsed as GeminiStreamEvent
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stringifyPayload(value: unknown): string {
|
|
61
|
+
if (typeof value === "string") return value
|
|
62
|
+
if (value == null) return ""
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(value, null, 2)
|
|
65
|
+
} catch {
|
|
66
|
+
return String(value)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseObjectPayload(value: unknown): Record<string, unknown> {
|
|
71
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
72
|
+
return value as Record<string, unknown>
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(value)
|
|
77
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
78
|
+
return parsed as Record<string, unknown>
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore invalid JSON payloads and fall back to an empty record.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractResultMessage(event: GeminiStreamEvent): string {
|
|
88
|
+
const directMessage = typeof event.message === "string" ? event.message.trim() : ""
|
|
89
|
+
if (directMessage) return directMessage
|
|
90
|
+
|
|
91
|
+
const errorText = stringifyPayload(event.error).trim()
|
|
92
|
+
if (errorText) return errorText
|
|
93
|
+
|
|
94
|
+
const detailsText = stringifyPayload(event.details).trim()
|
|
95
|
+
if (detailsText) return detailsText
|
|
96
|
+
|
|
97
|
+
return "Turn failed"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeDiagnosticLine(line: string): string | null {
|
|
101
|
+
const trimmed = line.trim()
|
|
102
|
+
if (!trimmed || GEMINI_STDIO_NOISE.has(trimmed)) return null
|
|
103
|
+
return trimmed
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Event normalisation — maps Gemini stream-json events to TranscriptEntry[]
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
function normalizeGeminiStreamEvent(event: GeminiStreamEvent, model: string): {
|
|
111
|
+
entries: TranscriptEntry[]
|
|
112
|
+
sessionId?: string
|
|
113
|
+
/** assistant text delta to accumulate (emitted as one entry on result) */
|
|
114
|
+
textDelta?: string
|
|
115
|
+
} {
|
|
116
|
+
const debugRaw = JSON.stringify(event)
|
|
117
|
+
|
|
118
|
+
switch (event.type) {
|
|
119
|
+
case "init": {
|
|
120
|
+
// Actual field is session_id (snake_case)
|
|
121
|
+
const sessionId = typeof event.session_id === "string" ? event.session_id : undefined
|
|
122
|
+
const eventModel = typeof event.model === "string" ? event.model : model
|
|
123
|
+
return {
|
|
124
|
+
sessionId,
|
|
125
|
+
entries: [
|
|
126
|
+
timestamped({
|
|
127
|
+
kind: "system_init",
|
|
128
|
+
provider: "gemini",
|
|
129
|
+
model: eventModel,
|
|
130
|
+
tools: [],
|
|
131
|
+
agents: [],
|
|
132
|
+
slashCommands: [],
|
|
133
|
+
mcpServers: [],
|
|
134
|
+
debugRaw,
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case "message": {
|
|
141
|
+
const role = typeof event.role === "string" ? event.role : "assistant"
|
|
142
|
+
const content = typeof event.content === "string" ? event.content : ""
|
|
143
|
+
// All assistant messages come with delta:true (streaming chunks).
|
|
144
|
+
// Accumulate them so we can emit one complete entry at result time.
|
|
145
|
+
if (role === "assistant" && content) {
|
|
146
|
+
return { entries: [], textDelta: content }
|
|
147
|
+
}
|
|
148
|
+
return { entries: [] }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "tool_use": {
|
|
152
|
+
// Actual fields: tool_name, tool_id, parameters
|
|
153
|
+
const toolName = typeof event.tool_name === "string"
|
|
154
|
+
? event.tool_name
|
|
155
|
+
: typeof event.name === "string"
|
|
156
|
+
? event.name
|
|
157
|
+
: "unknown"
|
|
158
|
+
const toolId = typeof event.tool_id === "string" ? event.tool_id : randomUUID()
|
|
159
|
+
const args = parseObjectPayload(event.parameters ?? event.args)
|
|
160
|
+
|
|
161
|
+
if (toolName === "codebase_investigator" || toolName === "cli_help") {
|
|
162
|
+
const tool = normalizeToolCall({
|
|
163
|
+
toolName,
|
|
164
|
+
toolId,
|
|
165
|
+
input: {
|
|
166
|
+
...args,
|
|
167
|
+
subagent_type: toolName,
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
entries: [
|
|
173
|
+
timestamped({
|
|
174
|
+
kind: "tool_call",
|
|
175
|
+
tool,
|
|
176
|
+
debugRaw,
|
|
177
|
+
}),
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const mapped = mapGeminiToolName(toolName)
|
|
183
|
+
const tool = normalizeToolCall({ toolName: mapped, toolId, input: args })
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
entries: [
|
|
187
|
+
timestamped({
|
|
188
|
+
kind: "tool_call",
|
|
189
|
+
tool,
|
|
190
|
+
debugRaw,
|
|
191
|
+
}),
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "tool_result": {
|
|
197
|
+
// Actual fields: tool_id, status, output, error
|
|
198
|
+
const toolId = typeof event.tool_id === "string" ? event.tool_id : randomUUID()
|
|
199
|
+
const output = stringifyPayload(event.output)
|
|
200
|
+
const errorText = stringifyPayload(event.error)
|
|
201
|
+
const isError = event.status === "error"
|
|
202
|
+
return {
|
|
203
|
+
entries: [
|
|
204
|
+
timestamped({
|
|
205
|
+
kind: "tool_result",
|
|
206
|
+
toolId,
|
|
207
|
+
content: isError ? errorText || output : output,
|
|
208
|
+
isError,
|
|
209
|
+
debugRaw,
|
|
210
|
+
}),
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "error": {
|
|
216
|
+
const message = typeof event.message === "string" ? event.message : "Unknown error"
|
|
217
|
+
return {
|
|
218
|
+
entries: [
|
|
219
|
+
timestamped({
|
|
220
|
+
kind: "status",
|
|
221
|
+
status: `Error: ${message}`,
|
|
222
|
+
debugRaw,
|
|
223
|
+
}),
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case "result": {
|
|
229
|
+
// Actual fields: status ('success'|'error'), stats.duration_ms
|
|
230
|
+
const success = event.status === "success"
|
|
231
|
+
const stats = (event.stats && typeof event.stats === "object" ? event.stats : {}) as Record<string, unknown>
|
|
232
|
+
const durationMs = typeof stats.duration_ms === "number" ? stats.duration_ms : 0
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
entries: [
|
|
236
|
+
timestamped({
|
|
237
|
+
kind: "result",
|
|
238
|
+
subtype: success ? "success" : "error",
|
|
239
|
+
isError: !success,
|
|
240
|
+
durationMs,
|
|
241
|
+
result: success ? "Turn completed" : extractResultMessage(event),
|
|
242
|
+
costUsd: undefined,
|
|
243
|
+
}),
|
|
244
|
+
],
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
default:
|
|
249
|
+
return { entries: [] }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Maps Gemini CLI tool names to the tool names that `normalizeToolCall()` expects.
|
|
255
|
+
* Names sourced from @google/gemini-cli-core ALL_BUILTIN_TOOL_NAMES.
|
|
256
|
+
*/
|
|
257
|
+
function mapGeminiToolName(geminiName: string): string {
|
|
258
|
+
switch (geminiName) {
|
|
259
|
+
case "read_file":
|
|
260
|
+
return "Read"
|
|
261
|
+
case "read_many_files":
|
|
262
|
+
return "Read"
|
|
263
|
+
case "write_file":
|
|
264
|
+
return "Write"
|
|
265
|
+
case "replace": // Gemini's edit/replace tool
|
|
266
|
+
return "Edit"
|
|
267
|
+
case "run_shell_command":
|
|
268
|
+
return "Bash"
|
|
269
|
+
case "google_web_search":
|
|
270
|
+
return "WebSearch"
|
|
271
|
+
case "web_fetch":
|
|
272
|
+
return "WebFetch"
|
|
273
|
+
case "glob":
|
|
274
|
+
return "Glob"
|
|
275
|
+
case "grep_search":
|
|
276
|
+
return "Grep"
|
|
277
|
+
case "list_directory":
|
|
278
|
+
case "list_directory_legacy": // legacy alias
|
|
279
|
+
return "LS"
|
|
280
|
+
default:
|
|
281
|
+
return geminiName
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// AsyncQueue — minimal async-iterable queue (same pattern as codex-app-server)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
290
|
+
private values: T[] = []
|
|
291
|
+
private resolvers: Array<(value: IteratorResult<T>) => void> = []
|
|
292
|
+
private done = false
|
|
293
|
+
|
|
294
|
+
push(value: T) {
|
|
295
|
+
if (this.done) return
|
|
296
|
+
const resolver = this.resolvers.shift()
|
|
297
|
+
if (resolver) {
|
|
298
|
+
resolver({ value, done: false })
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
this.values.push(value)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
finish() {
|
|
305
|
+
if (this.done) return
|
|
306
|
+
this.done = true
|
|
307
|
+
while (this.resolvers.length > 0) {
|
|
308
|
+
const resolver = this.resolvers.shift()
|
|
309
|
+
resolver?.({ value: undefined as T, done: true })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
314
|
+
return {
|
|
315
|
+
next: () => {
|
|
316
|
+
if (this.values.length > 0) {
|
|
317
|
+
return Promise.resolve({ value: this.values.shift() as T, done: false })
|
|
318
|
+
}
|
|
319
|
+
if (this.done) {
|
|
320
|
+
return Promise.resolve({ value: undefined as T, done: true })
|
|
321
|
+
}
|
|
322
|
+
return new Promise<IteratorResult<T>>((resolve) => {
|
|
323
|
+
this.resolvers.push(resolve)
|
|
324
|
+
})
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// GeminiCliManager
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export class GeminiCliManager {
|
|
335
|
+
private activeProcesses = new Map<string, ChildProcess>()
|
|
336
|
+
private readonly systemSettingsPath: string
|
|
337
|
+
|
|
338
|
+
constructor() {
|
|
339
|
+
this.systemSettingsPath = join(tmpdir(), `kaizen-gemini-settings-${process.pid}.json`)
|
|
340
|
+
writeFileSync(this.systemSettingsPath, JSON.stringify({
|
|
341
|
+
agents: {
|
|
342
|
+
overrides: {
|
|
343
|
+
codebase_investigator: {
|
|
344
|
+
enabled: false,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
}))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Start a new turn by spawning `gemini` in headless mode with stream-json output.
|
|
353
|
+
* Returns a HarnessTurn compatible with AgentCoordinator.runTurn().
|
|
354
|
+
*/
|
|
355
|
+
async startTurn(args: StartGeminiTurnArgs): Promise<HarnessTurn> {
|
|
356
|
+
const cliArgs = [
|
|
357
|
+
"-p", args.content,
|
|
358
|
+
"--output-format", "stream-json",
|
|
359
|
+
"--model", args.model,
|
|
360
|
+
"--approval-mode", "yolo",
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
// Resume previous session if we have a token
|
|
364
|
+
if (args.sessionToken) {
|
|
365
|
+
cliArgs.push("--resume", args.sessionToken)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const child = spawn("gemini", cliArgs, {
|
|
369
|
+
cwd: args.localPath,
|
|
370
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
371
|
+
env: {
|
|
372
|
+
...process.env,
|
|
373
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: this.systemSettingsPath,
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const queue = new AsyncQueue<HarnessEvent>()
|
|
378
|
+
let sessionId: string | null = null
|
|
379
|
+
const stderrChunks: string[] = []
|
|
380
|
+
let assistantTextAccum = ""
|
|
381
|
+
let sawFinalResult = false
|
|
382
|
+
let lastDiagnosticMessage: string | null = null
|
|
383
|
+
|
|
384
|
+
// Track the process for interrupt/close
|
|
385
|
+
const processId = randomUUID()
|
|
386
|
+
this.activeProcesses.set(processId, child)
|
|
387
|
+
|
|
388
|
+
// Read stderr for diagnostics
|
|
389
|
+
if (child.stderr) {
|
|
390
|
+
const stderrRl = createInterface({ input: child.stderr })
|
|
391
|
+
stderrRl.on("line", (line: string) => {
|
|
392
|
+
stderrChunks.push(`${line}\n`)
|
|
393
|
+
const diagnostic = normalizeDiagnosticLine(line)
|
|
394
|
+
if (!diagnostic) return
|
|
395
|
+
lastDiagnosticMessage = diagnostic
|
|
396
|
+
queue.push({
|
|
397
|
+
type: "transcript",
|
|
398
|
+
entry: timestamped({
|
|
399
|
+
kind: "status",
|
|
400
|
+
status: diagnostic,
|
|
401
|
+
}),
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Parse stdout line-by-line as JSONL
|
|
407
|
+
if (child.stdout) {
|
|
408
|
+
const rl = createInterface({ input: child.stdout })
|
|
409
|
+
|
|
410
|
+
rl.on("line", (line: string) => {
|
|
411
|
+
const trimmed = line.trim()
|
|
412
|
+
if (!trimmed) return
|
|
413
|
+
|
|
414
|
+
const event = parseJsonLine(trimmed)
|
|
415
|
+
if (!event) return
|
|
416
|
+
|
|
417
|
+
const result = normalizeGeminiStreamEvent(event, args.model)
|
|
418
|
+
|
|
419
|
+
// Capture session ID for resume capability
|
|
420
|
+
if (result.sessionId) {
|
|
421
|
+
sessionId = result.sessionId
|
|
422
|
+
queue.push({ type: "session_token", sessionToken: sessionId })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Accumulate assistant text deltas — Gemini streams all text as delta:true chunks
|
|
426
|
+
if (result.textDelta) {
|
|
427
|
+
assistantTextAccum += result.textDelta
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Before emitting the result entry, flush the accumulated assistant text
|
|
431
|
+
if (event.type === "result" && assistantTextAccum) {
|
|
432
|
+
queue.push({
|
|
433
|
+
type: "transcript",
|
|
434
|
+
entry: timestamped({ kind: "assistant_text", text: assistantTextAccum }),
|
|
435
|
+
})
|
|
436
|
+
assistantTextAccum = ""
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (event.type === "result") {
|
|
440
|
+
sawFinalResult = true
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const entry of result.entries) {
|
|
444
|
+
queue.push({ type: "transcript", entry })
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
rl.on("close", () => {
|
|
449
|
+
// Flush any remaining accumulated text if process ends without a result event
|
|
450
|
+
if (assistantTextAccum) {
|
|
451
|
+
queue.push({
|
|
452
|
+
type: "transcript",
|
|
453
|
+
entry: timestamped({ kind: "assistant_text", text: assistantTextAccum }),
|
|
454
|
+
})
|
|
455
|
+
assistantTextAccum = ""
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Handle process exit
|
|
461
|
+
child.on("close", (code) => {
|
|
462
|
+
this.activeProcesses.delete(processId)
|
|
463
|
+
|
|
464
|
+
// If we haven't received a "result" event, synthesise one from the exit code
|
|
465
|
+
if (!sawFinalResult && code !== 0 && code !== null) {
|
|
466
|
+
const errorMessage = stderrChunks.join("").trim() || lastDiagnosticMessage || `Gemini CLI exited with code ${code}`
|
|
467
|
+
queue.push({
|
|
468
|
+
type: "transcript",
|
|
469
|
+
entry: timestamped({
|
|
470
|
+
kind: "result",
|
|
471
|
+
subtype: "error",
|
|
472
|
+
isError: true,
|
|
473
|
+
durationMs: 0,
|
|
474
|
+
result: errorMessage,
|
|
475
|
+
}),
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
queue.finish()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
child.on("error", (error) => {
|
|
483
|
+
this.activeProcesses.delete(processId)
|
|
484
|
+
queue.push({
|
|
485
|
+
type: "transcript",
|
|
486
|
+
entry: timestamped({
|
|
487
|
+
kind: "result",
|
|
488
|
+
subtype: "error",
|
|
489
|
+
isError: true,
|
|
490
|
+
durationMs: 0,
|
|
491
|
+
result: error.message.includes("ENOENT")
|
|
492
|
+
? "Gemini CLI not found. Install it with: npm install -g @google/gemini-cli"
|
|
493
|
+
: `Gemini CLI error: ${error.message}`,
|
|
494
|
+
}),
|
|
495
|
+
})
|
|
496
|
+
queue.finish()
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
provider: "gemini",
|
|
501
|
+
stream: queue,
|
|
502
|
+
interrupt: async () => {
|
|
503
|
+
if (!child.killed) {
|
|
504
|
+
child.kill("SIGINT")
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
close: () => {
|
|
508
|
+
if (!child.killed) {
|
|
509
|
+
child.kill("SIGTERM")
|
|
510
|
+
}
|
|
511
|
+
this.activeProcesses.delete(processId)
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Stop all active Gemini processes (e.g. on server shutdown). */
|
|
517
|
+
stopAll() {
|
|
518
|
+
for (const [id, child] of this.activeProcesses) {
|
|
519
|
+
if (!child.killed) {
|
|
520
|
+
child.kill("SIGTERM")
|
|
521
|
+
}
|
|
522
|
+
this.activeProcesses.delete(id)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { QuickResponseAdapter } from "./quick-response"
|
|
2
|
+
|
|
3
|
+
const TITLE_SCHEMA = {
|
|
4
|
+
type: "object",
|
|
5
|
+
properties: {
|
|
6
|
+
title: { type: "string" },
|
|
7
|
+
},
|
|
8
|
+
required: ["title"],
|
|
9
|
+
additionalProperties: false,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
function normalizeGeneratedTitle(value: unknown): string | null {
|
|
13
|
+
if (typeof value !== "string") return null
|
|
14
|
+
const normalized = value.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
15
|
+
if (!normalized || normalized === "New Chat") return null
|
|
16
|
+
return normalized
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function generateTitleForChat(
|
|
20
|
+
messageContent: string,
|
|
21
|
+
cwd: string,
|
|
22
|
+
adapter = new QuickResponseAdapter()
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
const result = await adapter.generateStructured<string>({
|
|
25
|
+
cwd,
|
|
26
|
+
task: "conversation title generation",
|
|
27
|
+
prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
|
|
28
|
+
schema: TITLE_SCHEMA,
|
|
29
|
+
parse: (value) => {
|
|
30
|
+
const output = value && typeof value === "object" ? value as { title?: unknown } : {}
|
|
31
|
+
return normalizeGeneratedTitle(output.title)
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import type { GitBranchesResult, GitCreateBranchResult, GitSwitchBranchResult } from "../shared/protocol"
|
|
4
|
+
import { CLI_COMMAND, PROJECT_METADATA_DIR_NAME } from "../shared/branding"
|
|
5
|
+
|
|
6
|
+
export class GitManager {
|
|
7
|
+
private async runGit(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
8
|
+
const proc = Bun.spawn(["git", "-C", cwd, ...args], {
|
|
9
|
+
stdout: "pipe",
|
|
10
|
+
stderr: "pipe",
|
|
11
|
+
})
|
|
12
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
13
|
+
proc.exited,
|
|
14
|
+
new Response(proc.stdout).text(),
|
|
15
|
+
new Response(proc.stderr).text(),
|
|
16
|
+
])
|
|
17
|
+
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getBranches(localPath: string): Promise<GitBranchesResult> {
|
|
21
|
+
const [branchResult, headResult] = await Promise.all([
|
|
22
|
+
this.runGit(localPath, ["branch"]),
|
|
23
|
+
this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
if (branchResult.exitCode !== 0 || headResult.exitCode !== 0) {
|
|
27
|
+
return { isRepo: false, currentBranch: null, branches: [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const branches = branchResult.stdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.filter((line) => line.trim().length > 0)
|
|
33
|
+
.map((line) => line.slice(2)) // strip "* " or " " prefix
|
|
34
|
+
.sort((a, b) => a.localeCompare(b))
|
|
35
|
+
|
|
36
|
+
const currentBranch = headResult.stdout === "HEAD" ? null : headResult.stdout
|
|
37
|
+
|
|
38
|
+
return { isRepo: true, currentBranch, branches }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async switchBranch(localPath: string, branchName: string): Promise<GitSwitchBranchResult> {
|
|
42
|
+
const result = await this.runGit(localPath, ["switch", branchName])
|
|
43
|
+
if (result.exitCode !== 0) {
|
|
44
|
+
throw new Error(result.stderr || `Failed to switch to branch "${branchName}"`)
|
|
45
|
+
}
|
|
46
|
+
const head = await this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
47
|
+
return { currentBranch: head.stdout }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async createBranch(localPath: string, branchName: string, checkout: boolean): Promise<GitCreateBranchResult> {
|
|
51
|
+
const args = checkout ? ["switch", "-c", branchName] : ["branch", branchName]
|
|
52
|
+
const result = await this.runGit(localPath, args)
|
|
53
|
+
if (result.exitCode !== 0) {
|
|
54
|
+
throw new Error(result.stderr || `Failed to create branch "${branchName}"`)
|
|
55
|
+
}
|
|
56
|
+
const head = await this.runGit(localPath, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
57
|
+
return { currentBranch: head.stdout }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async setProjectMetadataDirectoryCommitMode(localPath: string, commitProjectMetadata: boolean) {
|
|
61
|
+
// Never allow the app repo itself to commit its metadata directory.
|
|
62
|
+
const effectiveCommitMetadata = path.basename(localPath).toLowerCase() === CLI_COMMAND
|
|
63
|
+
? false
|
|
64
|
+
: commitProjectMetadata
|
|
65
|
+
const gitignorePath = `${localPath}/.gitignore`
|
|
66
|
+
const existing = await readFile(gitignorePath, "utf8").catch(() => "")
|
|
67
|
+
const lines = existing ? existing.split(/\r?\n/) : []
|
|
68
|
+
const filtered = lines.filter((line) => {
|
|
69
|
+
const trimmed = line.trim()
|
|
70
|
+
return trimmed !== PROJECT_METADATA_DIR_NAME
|
|
71
|
+
&& trimmed !== `${PROJECT_METADATA_DIR_NAME}/`
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const nextLines = effectiveCommitMetadata ? filtered : [...filtered, `${PROJECT_METADATA_DIR_NAME}/`]
|
|
75
|
+
const normalized = nextLines.join("\n").replace(/\n{3,}/g, "\n\n")
|
|
76
|
+
const nextContent = normalized.trim().length > 0 ? `${normalized.replace(/\n+$/g, "")}\n` : ""
|
|
77
|
+
await writeFile(gitignorePath, nextContent, "utf8")
|
|
78
|
+
}
|
|
79
|
+
}
|