sparkecoder 0.1.3 → 0.1.5

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.
@@ -1,8 +1,8 @@
1
1
  // src/tools/bash.ts
2
2
  import { tool } from "ai";
3
3
  import { z } from "zod";
4
- import { exec } from "child_process";
5
- import { promisify } from "util";
4
+ import { exec as exec2 } from "child_process";
5
+ import { promisify as promisify2 } from "util";
6
6
 
7
7
  // src/utils/truncate.ts
8
8
  var MAX_OUTPUT_CHARS = 1e4;
@@ -19,9 +19,253 @@ function truncateOutput(output, maxChars = MAX_OUTPUT_CHARS) {
19
19
  ` + output.slice(-halfMax);
20
20
  }
21
21
 
22
- // src/tools/bash.ts
22
+ // src/terminal/tmux.ts
23
+ import { exec } from "child_process";
24
+ import { promisify } from "util";
25
+ import { mkdir, writeFile, readFile } from "fs/promises";
26
+ import { existsSync } from "fs";
27
+ import { join } from "path";
28
+ import { nanoid } from "nanoid";
23
29
  var execAsync = promisify(exec);
24
- var COMMAND_TIMEOUT = 6e4;
30
+ var SESSION_PREFIX = "spark_";
31
+ var LOG_BASE_DIR = ".sparkecoder/sessions";
32
+ var tmuxAvailableCache = null;
33
+ async function isTmuxAvailable() {
34
+ if (tmuxAvailableCache !== null) {
35
+ return tmuxAvailableCache;
36
+ }
37
+ try {
38
+ const { stdout } = await execAsync("tmux -V");
39
+ tmuxAvailableCache = true;
40
+ return true;
41
+ } catch (error) {
42
+ tmuxAvailableCache = false;
43
+ console.log(`[tmux] Not available: ${error instanceof Error ? error.message : "unknown error"}`);
44
+ return false;
45
+ }
46
+ }
47
+ function generateTerminalId() {
48
+ return "t" + nanoid(9);
49
+ }
50
+ function getSessionName(terminalId) {
51
+ return `${SESSION_PREFIX}${terminalId}`;
52
+ }
53
+ function getLogDir(terminalId, workingDirectory, sessionId) {
54
+ if (sessionId) {
55
+ return join(workingDirectory, LOG_BASE_DIR, sessionId, "terminals", terminalId);
56
+ }
57
+ return join(workingDirectory, ".sparkecoder/terminals", terminalId);
58
+ }
59
+ function shellEscape(str) {
60
+ return `'${str.replace(/'/g, "'\\''")}'`;
61
+ }
62
+ async function initLogDir(terminalId, meta, workingDirectory) {
63
+ const logDir = getLogDir(terminalId, workingDirectory, meta.sessionId);
64
+ await mkdir(logDir, { recursive: true });
65
+ await writeFile(join(logDir, "meta.json"), JSON.stringify(meta, null, 2));
66
+ await writeFile(join(logDir, "output.log"), "");
67
+ return logDir;
68
+ }
69
+ async function pollUntil(condition, options) {
70
+ const { timeout, interval = 100 } = options;
71
+ const startTime = Date.now();
72
+ while (Date.now() - startTime < timeout) {
73
+ if (await condition()) {
74
+ return true;
75
+ }
76
+ await new Promise((r) => setTimeout(r, interval));
77
+ }
78
+ return false;
79
+ }
80
+ async function runSync(command, workingDirectory, options) {
81
+ if (!options) {
82
+ throw new Error("runSync: options parameter is required (must include sessionId)");
83
+ }
84
+ const id = options.terminalId || generateTerminalId();
85
+ const session = getSessionName(id);
86
+ const logDir = await initLogDir(id, {
87
+ id,
88
+ command,
89
+ cwd: workingDirectory,
90
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
91
+ sessionId: options.sessionId,
92
+ background: false
93
+ }, workingDirectory);
94
+ const logFile = join(logDir, "output.log");
95
+ const exitCodeFile = join(logDir, "exit_code");
96
+ const timeout = options.timeout || 12e4;
97
+ try {
98
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}; echo $? > ${shellEscape(exitCodeFile)}`;
99
+ await execAsync(
100
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
101
+ { timeout: 5e3 }
102
+ );
103
+ try {
104
+ await execAsync(
105
+ `tmux pipe-pane -t ${session} -o 'cat >> ${shellEscape(logFile)}'`,
106
+ { timeout: 1e3 }
107
+ );
108
+ } catch {
109
+ }
110
+ const completed = await pollUntil(
111
+ async () => {
112
+ try {
113
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
114
+ return false;
115
+ } catch {
116
+ return true;
117
+ }
118
+ },
119
+ { timeout, interval: 100 }
120
+ );
121
+ if (!completed) {
122
+ try {
123
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
124
+ } catch {
125
+ }
126
+ let output2 = "";
127
+ try {
128
+ output2 = await readFile(logFile, "utf-8");
129
+ } catch {
130
+ }
131
+ return {
132
+ id,
133
+ output: output2.trim(),
134
+ exitCode: 124,
135
+ // Standard timeout exit code
136
+ status: "error"
137
+ };
138
+ }
139
+ await new Promise((r) => setTimeout(r, 50));
140
+ let output = "";
141
+ try {
142
+ output = await readFile(logFile, "utf-8");
143
+ } catch {
144
+ }
145
+ let exitCode = 0;
146
+ try {
147
+ if (existsSync(exitCodeFile)) {
148
+ const exitCodeStr = await readFile(exitCodeFile, "utf-8");
149
+ exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
150
+ }
151
+ } catch {
152
+ }
153
+ return {
154
+ id,
155
+ output: output.trim(),
156
+ exitCode,
157
+ status: "completed"
158
+ };
159
+ } catch (error) {
160
+ try {
161
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
162
+ } catch {
163
+ }
164
+ throw error;
165
+ }
166
+ }
167
+ async function runBackground(command, workingDirectory, options) {
168
+ if (!options) {
169
+ throw new Error("runBackground: options parameter is required (must include sessionId)");
170
+ }
171
+ const id = options.terminalId || generateTerminalId();
172
+ const session = getSessionName(id);
173
+ const logDir = await initLogDir(id, {
174
+ id,
175
+ command,
176
+ cwd: workingDirectory,
177
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
178
+ sessionId: options.sessionId,
179
+ background: true
180
+ }, workingDirectory);
181
+ const logFile = join(logDir, "output.log");
182
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}`;
183
+ await execAsync(
184
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
185
+ { timeout: 5e3 }
186
+ );
187
+ return {
188
+ id,
189
+ output: "",
190
+ exitCode: 0,
191
+ status: "running"
192
+ };
193
+ }
194
+ async function getLogs(terminalId, workingDirectory, options = {}) {
195
+ const session = getSessionName(terminalId);
196
+ const logDir = getLogDir(terminalId, workingDirectory, options.sessionId);
197
+ const logFile = join(logDir, "output.log");
198
+ let isRunning = false;
199
+ try {
200
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
201
+ isRunning = true;
202
+ } catch {
203
+ }
204
+ if (isRunning) {
205
+ try {
206
+ const lines = options.tail || 1e3;
207
+ const { stdout } = await execAsync(
208
+ `tmux capture-pane -t ${session} -p -S -${lines}`,
209
+ { timeout: 5e3, maxBuffer: 10 * 1024 * 1024 }
210
+ );
211
+ return { output: stdout.trim(), status: "running" };
212
+ } catch {
213
+ }
214
+ }
215
+ try {
216
+ let output = await readFile(logFile, "utf-8");
217
+ if (options.tail) {
218
+ const lines = output.split("\n");
219
+ output = lines.slice(-options.tail).join("\n");
220
+ }
221
+ return { output: output.trim(), status: isRunning ? "running" : "stopped" };
222
+ } catch {
223
+ return { output: "", status: "unknown" };
224
+ }
225
+ }
226
+ async function killTerminal(terminalId) {
227
+ const session = getSessionName(terminalId);
228
+ try {
229
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+ async function sendInput(terminalId, input, options = {}) {
236
+ const session = getSessionName(terminalId);
237
+ const { pressEnter = true } = options;
238
+ try {
239
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
240
+ await execAsync(
241
+ `tmux send-keys -t ${session} -l ${shellEscape(input)}`,
242
+ { timeout: 1e3 }
243
+ );
244
+ if (pressEnter) {
245
+ await execAsync(
246
+ `tmux send-keys -t ${session} Enter`,
247
+ { timeout: 1e3 }
248
+ );
249
+ }
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+ async function sendKey(terminalId, key) {
256
+ const session = getSessionName(terminalId);
257
+ try {
258
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
259
+ await execAsync(`tmux send-keys -t ${session} ${key}`, { timeout: 1e3 });
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+
266
+ // src/tools/bash.ts
267
+ var execAsync2 = promisify2(exec2);
268
+ var COMMAND_TIMEOUT = 12e4;
25
269
  var MAX_OUTPUT_CHARS2 = 1e4;
26
270
  var BLOCKED_COMMANDS = [
27
271
  "rm -rf /",
@@ -38,66 +282,226 @@ function isBlockedCommand(command) {
38
282
  );
39
283
  }
40
284
  var bashInputSchema = z.object({
41
- command: z.string().describe("The bash command to execute. Can be a single command or a pipeline.")
285
+ command: z.string().optional().describe("The command to execute. Required for running new commands."),
286
+ background: z.boolean().default(false).describe("Run the command in background mode (for dev servers, watchers). Returns immediately with terminal ID."),
287
+ id: z.string().optional().describe("Terminal ID. Use to get logs from, send input to, or kill an existing terminal."),
288
+ kill: z.boolean().optional().describe("Kill the terminal with the given ID."),
289
+ tail: z.number().optional().describe("Number of lines to return from the end of output (for logs)."),
290
+ input: z.string().optional().describe("Send text input to an interactive terminal (requires id). Used for responding to prompts."),
291
+ key: z.enum(["Enter", "Escape", "Up", "Down", "Left", "Right", "Tab", "C-c", "C-d", "y", "n"]).optional().describe('Send a special key to an interactive terminal (requires id). Use "y" or "n" for yes/no prompts.')
42
292
  });
293
+ var useTmux = null;
294
+ async function shouldUseTmux() {
295
+ if (useTmux === null) {
296
+ useTmux = await isTmuxAvailable();
297
+ if (!useTmux) {
298
+ console.warn("[bash] tmux not available, using fallback exec mode");
299
+ }
300
+ }
301
+ return useTmux;
302
+ }
303
+ async function execFallback(command, workingDirectory, onOutput) {
304
+ try {
305
+ const { stdout, stderr } = await execAsync2(command, {
306
+ cwd: workingDirectory,
307
+ timeout: COMMAND_TIMEOUT,
308
+ maxBuffer: 10 * 1024 * 1024
309
+ });
310
+ const output = truncateOutput(stdout + (stderr ? `
311
+ ${stderr}` : ""), MAX_OUTPUT_CHARS2);
312
+ onOutput?.(output);
313
+ return {
314
+ success: true,
315
+ output,
316
+ exitCode: 0
317
+ };
318
+ } catch (error) {
319
+ const output = truncateOutput(
320
+ (error.stdout || "") + (error.stderr ? `
321
+ ${error.stderr}` : ""),
322
+ MAX_OUTPUT_CHARS2
323
+ );
324
+ onOutput?.(output || error.message);
325
+ if (error.killed) {
326
+ return {
327
+ success: false,
328
+ error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
329
+ output,
330
+ exitCode: 124
331
+ };
332
+ }
333
+ return {
334
+ success: false,
335
+ error: error.message,
336
+ output,
337
+ exitCode: error.code ?? 1
338
+ };
339
+ }
340
+ }
43
341
  function createBashTool(options) {
44
342
  return tool({
45
- description: `Execute a bash command in the terminal. The command runs in the working directory: ${options.workingDirectory}.
46
- Use this for running shell commands, scripts, git operations, package managers (npm, pip, etc.), and other CLI tools.
47
- Long outputs will be automatically truncated. Commands have a 60 second timeout.
48
- IMPORTANT: Avoid destructive commands. Be careful with rm, chmod, and similar operations.`,
343
+ description: `Execute commands in the terminal. Every command runs in its own session with logs saved to disk.
344
+
345
+ **Run a command (default - waits for completion):**
346
+ bash({ command: "npm install" })
347
+ bash({ command: "git status" })
348
+
349
+ **Run in background (for dev servers, watchers, or interactive commands):**
350
+ bash({ command: "npm run dev", background: true })
351
+ \u2192 Returns { id: "abc123" } - save this ID
352
+
353
+ **Check on a background process:**
354
+ bash({ id: "abc123" })
355
+ bash({ id: "abc123", tail: 50 }) // last 50 lines only
356
+
357
+ **Stop a background process:**
358
+ bash({ id: "abc123", kill: true })
359
+
360
+ **Respond to interactive prompts (for yes/no questions, etc.):**
361
+ bash({ id: "abc123", key: "y" }) // send 'y' for yes
362
+ bash({ id: "abc123", key: "n" }) // send 'n' for no
363
+ bash({ id: "abc123", key: "Enter" }) // press Enter
364
+ bash({ id: "abc123", input: "my text" }) // send text input
365
+
366
+ **IMPORTANT for interactive commands:**
367
+ - Use --yes, -y, or similar flags to avoid prompts when available
368
+ - For create-next-app: add --yes to accept defaults
369
+ - For npm: add --yes or -y to skip confirmation
370
+ - If prompts are unavoidable, run in background mode and use input/key to respond
371
+
372
+ Logs are saved to .sparkecoder/terminals/{id}/output.log`,
49
373
  inputSchema: bashInputSchema,
50
- execute: async ({ command }) => {
374
+ execute: async (inputArgs) => {
375
+ const { command, background, id, kill, tail, input: textInput, key } = inputArgs;
376
+ if (id) {
377
+ if (kill) {
378
+ const success = await killTerminal(id);
379
+ return {
380
+ success,
381
+ id,
382
+ status: success ? "stopped" : "not_found",
383
+ message: success ? `Terminal ${id} stopped` : `Terminal ${id} not found or already stopped`
384
+ };
385
+ }
386
+ if (textInput !== void 0) {
387
+ const success = await sendInput(id, textInput, { pressEnter: true });
388
+ if (!success) {
389
+ return {
390
+ success: false,
391
+ id,
392
+ error: `Terminal ${id} not found or not running`
393
+ };
394
+ }
395
+ await new Promise((r) => setTimeout(r, 300));
396
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
397
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
398
+ return {
399
+ success: true,
400
+ id,
401
+ output: truncatedOutput2,
402
+ status: status2,
403
+ message: `Sent input "${textInput}" to terminal`
404
+ };
405
+ }
406
+ if (key) {
407
+ const success = await sendKey(id, key);
408
+ if (!success) {
409
+ return {
410
+ success: false,
411
+ id,
412
+ error: `Terminal ${id} not found or not running`
413
+ };
414
+ }
415
+ await new Promise((r) => setTimeout(r, 300));
416
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
417
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
418
+ return {
419
+ success: true,
420
+ id,
421
+ output: truncatedOutput2,
422
+ status: status2,
423
+ message: `Sent key "${key}" to terminal`
424
+ };
425
+ }
426
+ const { output, status } = await getLogs(id, options.workingDirectory, { tail, sessionId: options.sessionId });
427
+ const truncatedOutput = truncateOutput(output, MAX_OUTPUT_CHARS2);
428
+ return {
429
+ success: true,
430
+ id,
431
+ output: truncatedOutput,
432
+ status
433
+ };
434
+ }
435
+ if (!command) {
436
+ return {
437
+ success: false,
438
+ error: 'Either "command" (to run a new command) or "id" (to check/kill/send input) is required'
439
+ };
440
+ }
51
441
  if (isBlockedCommand(command)) {
52
442
  return {
53
443
  success: false,
54
444
  error: "This command is blocked for safety reasons.",
55
- stdout: "",
56
- stderr: "",
445
+ output: "",
57
446
  exitCode: 1
58
447
  };
59
448
  }
60
- try {
61
- const { stdout, stderr } = await execAsync(command, {
62
- cwd: options.workingDirectory,
63
- timeout: COMMAND_TIMEOUT,
64
- maxBuffer: 10 * 1024 * 1024,
65
- // 10MB buffer
66
- shell: "/bin/bash"
67
- });
68
- const truncatedStdout = truncateOutput(stdout, MAX_OUTPUT_CHARS2);
69
- const truncatedStderr = truncateOutput(stderr, MAX_OUTPUT_CHARS2 / 2);
70
- if (options.onOutput) {
71
- options.onOutput(truncatedStdout);
449
+ const canUseTmux = await shouldUseTmux();
450
+ if (background) {
451
+ if (!canUseTmux) {
452
+ return {
453
+ success: false,
454
+ error: "Background mode requires tmux to be installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
455
+ };
72
456
  }
457
+ const terminalId = generateTerminalId();
458
+ options.onProgress?.({ terminalId, status: "started", command });
459
+ const result = await runBackground(command, options.workingDirectory, {
460
+ sessionId: options.sessionId,
461
+ terminalId
462
+ });
73
463
  return {
74
464
  success: true,
75
- stdout: truncatedStdout,
76
- stderr: truncatedStderr,
77
- exitCode: 0
465
+ id: result.id,
466
+ status: "running",
467
+ message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
78
468
  };
79
- } catch (error) {
80
- const stdout = error.stdout ? truncateOutput(error.stdout, MAX_OUTPUT_CHARS2) : "";
81
- const stderr = error.stderr ? truncateOutput(error.stderr, MAX_OUTPUT_CHARS2) : "";
82
- if (options.onOutput) {
83
- options.onOutput(stderr || error.message);
84
- }
85
- if (error.killed) {
469
+ }
470
+ if (canUseTmux) {
471
+ const terminalId = generateTerminalId();
472
+ options.onProgress?.({ terminalId, status: "started", command });
473
+ try {
474
+ const result = await runSync(command, options.workingDirectory, {
475
+ sessionId: options.sessionId,
476
+ timeout: COMMAND_TIMEOUT,
477
+ terminalId
478
+ });
479
+ const truncatedOutput = truncateOutput(result.output, MAX_OUTPUT_CHARS2);
480
+ options.onOutput?.(truncatedOutput);
481
+ options.onProgress?.({ terminalId, status: "completed", command });
482
+ return {
483
+ success: result.exitCode === 0,
484
+ id: result.id,
485
+ output: truncatedOutput,
486
+ exitCode: result.exitCode,
487
+ status: result.status
488
+ };
489
+ } catch (error) {
490
+ options.onProgress?.({ terminalId, status: "completed", command });
86
491
  return {
87
492
  success: false,
88
- error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
89
- stdout,
90
- stderr,
91
- exitCode: 124
92
- // Standard timeout exit code
493
+ error: error.message,
494
+ output: "",
495
+ exitCode: 1
93
496
  };
94
497
  }
498
+ } else {
499
+ const result = await execFallback(command, options.workingDirectory, options.onOutput);
95
500
  return {
96
- success: false,
97
- error: error.message,
98
- stdout,
99
- stderr,
100
- exitCode: error.code ?? 1
501
+ success: result.success,
502
+ output: result.output,
503
+ exitCode: result.exitCode,
504
+ error: result.error
101
505
  };
102
506
  }
103
507
  }
@@ -107,9 +511,9 @@ IMPORTANT: Avoid destructive commands. Be careful with rm, chmod, and similar op
107
511
  // src/tools/read-file.ts
108
512
  import { tool as tool2 } from "ai";
109
513
  import { z as z2 } from "zod";
110
- import { readFile, stat } from "fs/promises";
514
+ import { readFile as readFile2, stat } from "fs/promises";
111
515
  import { resolve, relative, isAbsolute } from "path";
112
- import { existsSync } from "fs";
516
+ import { existsSync as existsSync2 } from "fs";
113
517
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
114
518
  var MAX_OUTPUT_CHARS3 = 5e4;
115
519
  var readFileInputSchema = z2.object({
@@ -134,7 +538,7 @@ Use this to understand existing code, check file contents, or gather context.`,
134
538
  content: null
135
539
  };
136
540
  }
137
- if (!existsSync(absolutePath)) {
541
+ if (!existsSync2(absolutePath)) {
138
542
  return {
139
543
  success: false,
140
544
  error: `File not found: ${path}`,
@@ -156,7 +560,7 @@ Use this to understand existing code, check file contents, or gather context.`,
156
560
  content: null
157
561
  };
158
562
  }
159
- let content = await readFile(absolutePath, "utf-8");
563
+ let content = await readFile2(absolutePath, "utf-8");
160
564
  if (startLine !== void 0 || endLine !== void 0) {
161
565
  const lines = content.split("\n");
162
566
  const start = (startLine ?? 1) - 1;
@@ -204,9 +608,305 @@ Use this to understand existing code, check file contents, or gather context.`,
204
608
  // src/tools/write-file.ts
205
609
  import { tool as tool3 } from "ai";
206
610
  import { z as z3 } from "zod";
207
- import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
208
- import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, dirname } from "path";
209
- import { existsSync as existsSync2 } from "fs";
611
+ import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
612
+ import { resolve as resolve3, relative as relative3, isAbsolute as isAbsolute2, dirname as dirname2 } from "path";
613
+ import { existsSync as existsSync4 } from "fs";
614
+
615
+ // src/checkpoints/index.ts
616
+ import { readFile as readFile3, writeFile as writeFile2, unlink, mkdir as mkdir2 } from "fs/promises";
617
+ import { existsSync as existsSync3 } from "fs";
618
+ import { resolve as resolve2, relative as relative2, dirname } from "path";
619
+ import { exec as exec3 } from "child_process";
620
+ import { promisify as promisify3 } from "util";
621
+
622
+ // src/db/index.ts
623
+ import Database from "better-sqlite3";
624
+ import { drizzle } from "drizzle-orm/better-sqlite3";
625
+ import { eq, desc, and, sql } from "drizzle-orm";
626
+ import { nanoid as nanoid2 } from "nanoid";
627
+
628
+ // src/db/schema.ts
629
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
630
+ var sessions = sqliteTable("sessions", {
631
+ id: text("id").primaryKey(),
632
+ name: text("name"),
633
+ workingDirectory: text("working_directory").notNull(),
634
+ model: text("model").notNull(),
635
+ status: text("status", { enum: ["active", "waiting", "completed", "error"] }).notNull().default("active"),
636
+ config: text("config", { mode: "json" }).$type(),
637
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
638
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
639
+ });
640
+ var messages = sqliteTable("messages", {
641
+ id: text("id").primaryKey(),
642
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
643
+ // Store the entire ModelMessage as JSON (role + content)
644
+ modelMessage: text("model_message", { mode: "json" }).$type().notNull(),
645
+ // Sequence number within session to maintain exact ordering
646
+ sequence: integer("sequence").notNull().default(0),
647
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
648
+ });
649
+ var toolExecutions = sqliteTable("tool_executions", {
650
+ id: text("id").primaryKey(),
651
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
652
+ messageId: text("message_id").references(() => messages.id, { onDelete: "cascade" }),
653
+ toolName: text("tool_name").notNull(),
654
+ toolCallId: text("tool_call_id").notNull(),
655
+ input: text("input", { mode: "json" }),
656
+ output: text("output", { mode: "json" }),
657
+ status: text("status", { enum: ["pending", "approved", "rejected", "completed", "error"] }).notNull().default("pending"),
658
+ requiresApproval: integer("requires_approval", { mode: "boolean" }).notNull().default(false),
659
+ error: text("error"),
660
+ startedAt: integer("started_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
661
+ completedAt: integer("completed_at", { mode: "timestamp" })
662
+ });
663
+ var todoItems = sqliteTable("todo_items", {
664
+ id: text("id").primaryKey(),
665
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
666
+ content: text("content").notNull(),
667
+ status: text("status", { enum: ["pending", "in_progress", "completed", "cancelled"] }).notNull().default("pending"),
668
+ order: integer("order").notNull().default(0),
669
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
670
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
671
+ });
672
+ var loadedSkills = sqliteTable("loaded_skills", {
673
+ id: text("id").primaryKey(),
674
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
675
+ skillName: text("skill_name").notNull(),
676
+ loadedAt: integer("loaded_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
677
+ });
678
+ var terminals = sqliteTable("terminals", {
679
+ id: text("id").primaryKey(),
680
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
681
+ name: text("name"),
682
+ // Optional friendly name (e.g., "dev-server")
683
+ command: text("command").notNull(),
684
+ // The command that was run
685
+ cwd: text("cwd").notNull(),
686
+ // Working directory
687
+ pid: integer("pid"),
688
+ // Process ID (null if not running)
689
+ status: text("status", { enum: ["running", "stopped", "error"] }).notNull().default("running"),
690
+ exitCode: integer("exit_code"),
691
+ // Exit code if stopped
692
+ error: text("error"),
693
+ // Error message if status is 'error'
694
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
695
+ stoppedAt: integer("stopped_at", { mode: "timestamp" })
696
+ });
697
+ var activeStreams = sqliteTable("active_streams", {
698
+ id: text("id").primaryKey(),
699
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
700
+ streamId: text("stream_id").notNull().unique(),
701
+ // Unique stream identifier
702
+ status: text("status", { enum: ["active", "finished", "error"] }).notNull().default("active"),
703
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
704
+ finishedAt: integer("finished_at", { mode: "timestamp" })
705
+ });
706
+ var checkpoints = sqliteTable("checkpoints", {
707
+ id: text("id").primaryKey(),
708
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
709
+ // The message sequence number this checkpoint was created BEFORE
710
+ // (i.e., the state before this user message was processed)
711
+ messageSequence: integer("message_sequence").notNull(),
712
+ // Optional git commit hash if in a git repo
713
+ gitHead: text("git_head"),
714
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
715
+ });
716
+ var fileBackups = sqliteTable("file_backups", {
717
+ id: text("id").primaryKey(),
718
+ checkpointId: text("checkpoint_id").notNull().references(() => checkpoints.id, { onDelete: "cascade" }),
719
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
720
+ // Relative path from working directory
721
+ filePath: text("file_path").notNull(),
722
+ // Original content (null means file didn't exist before)
723
+ originalContent: text("original_content"),
724
+ // Whether the file existed before this checkpoint
725
+ existed: integer("existed", { mode: "boolean" }).notNull().default(true),
726
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
727
+ });
728
+
729
+ // src/db/index.ts
730
+ var db = null;
731
+ function getDb() {
732
+ if (!db) {
733
+ throw new Error("Database not initialized. Call initDatabase first.");
734
+ }
735
+ return db;
736
+ }
737
+ var todoQueries = {
738
+ create(data) {
739
+ const id = nanoid2();
740
+ const now = /* @__PURE__ */ new Date();
741
+ const result = getDb().insert(todoItems).values({
742
+ id,
743
+ ...data,
744
+ createdAt: now,
745
+ updatedAt: now
746
+ }).returning().get();
747
+ return result;
748
+ },
749
+ createMany(sessionId, items) {
750
+ const now = /* @__PURE__ */ new Date();
751
+ const values = items.map((item, index) => ({
752
+ id: nanoid2(),
753
+ sessionId,
754
+ content: item.content,
755
+ order: item.order ?? index,
756
+ createdAt: now,
757
+ updatedAt: now
758
+ }));
759
+ return getDb().insert(todoItems).values(values).returning().all();
760
+ },
761
+ getBySession(sessionId) {
762
+ return getDb().select().from(todoItems).where(eq(todoItems.sessionId, sessionId)).orderBy(todoItems.order).all();
763
+ },
764
+ updateStatus(id, status) {
765
+ return getDb().update(todoItems).set({ status, updatedAt: /* @__PURE__ */ new Date() }).where(eq(todoItems.id, id)).returning().get();
766
+ },
767
+ delete(id) {
768
+ const result = getDb().delete(todoItems).where(eq(todoItems.id, id)).run();
769
+ return result.changes > 0;
770
+ },
771
+ clearSession(sessionId) {
772
+ const result = getDb().delete(todoItems).where(eq(todoItems.sessionId, sessionId)).run();
773
+ return result.changes;
774
+ }
775
+ };
776
+ var skillQueries = {
777
+ load(sessionId, skillName) {
778
+ const id = nanoid2();
779
+ const result = getDb().insert(loadedSkills).values({
780
+ id,
781
+ sessionId,
782
+ skillName,
783
+ loadedAt: /* @__PURE__ */ new Date()
784
+ }).returning().get();
785
+ return result;
786
+ },
787
+ getBySession(sessionId) {
788
+ return getDb().select().from(loadedSkills).where(eq(loadedSkills.sessionId, sessionId)).orderBy(loadedSkills.loadedAt).all();
789
+ },
790
+ isLoaded(sessionId, skillName) {
791
+ const result = getDb().select().from(loadedSkills).where(
792
+ and(
793
+ eq(loadedSkills.sessionId, sessionId),
794
+ eq(loadedSkills.skillName, skillName)
795
+ )
796
+ ).get();
797
+ return !!result;
798
+ }
799
+ };
800
+ var fileBackupQueries = {
801
+ create(data) {
802
+ const id = nanoid2();
803
+ const result = getDb().insert(fileBackups).values({
804
+ id,
805
+ checkpointId: data.checkpointId,
806
+ sessionId: data.sessionId,
807
+ filePath: data.filePath,
808
+ originalContent: data.originalContent,
809
+ existed: data.existed,
810
+ createdAt: /* @__PURE__ */ new Date()
811
+ }).returning().get();
812
+ return result;
813
+ },
814
+ getByCheckpoint(checkpointId) {
815
+ return getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, checkpointId)).all();
816
+ },
817
+ getBySession(sessionId) {
818
+ return getDb().select().from(fileBackups).where(eq(fileBackups.sessionId, sessionId)).orderBy(fileBackups.createdAt).all();
819
+ },
820
+ /**
821
+ * Get all file backups from a given checkpoint sequence onwards (inclusive)
822
+ * (Used when reverting - need to restore these files)
823
+ *
824
+ * When reverting to checkpoint X, we need backups from checkpoint X and all later ones
825
+ * because checkpoint X's backups represent the state BEFORE processing message X.
826
+ */
827
+ getFromSequence(sessionId, messageSequence) {
828
+ const checkpointsFrom = getDb().select().from(checkpoints).where(
829
+ and(
830
+ eq(checkpoints.sessionId, sessionId),
831
+ sql`message_sequence >= ${messageSequence}`
832
+ )
833
+ ).all();
834
+ if (checkpointsFrom.length === 0) {
835
+ return [];
836
+ }
837
+ const checkpointIds = checkpointsFrom.map((c) => c.id);
838
+ const allBackups = [];
839
+ for (const cpId of checkpointIds) {
840
+ const backups = getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, cpId)).all();
841
+ allBackups.push(...backups);
842
+ }
843
+ return allBackups;
844
+ },
845
+ /**
846
+ * Check if a file already has a backup in the current checkpoint
847
+ */
848
+ hasBackup(checkpointId, filePath) {
849
+ const result = getDb().select().from(fileBackups).where(
850
+ and(
851
+ eq(fileBackups.checkpointId, checkpointId),
852
+ eq(fileBackups.filePath, filePath)
853
+ )
854
+ ).get();
855
+ return !!result;
856
+ },
857
+ deleteBySession(sessionId) {
858
+ const result = getDb().delete(fileBackups).where(eq(fileBackups.sessionId, sessionId)).run();
859
+ return result.changes;
860
+ }
861
+ };
862
+
863
+ // src/checkpoints/index.ts
864
+ var execAsync3 = promisify3(exec3);
865
+ var activeManagers = /* @__PURE__ */ new Map();
866
+ function getCheckpointManager(sessionId, workingDirectory) {
867
+ let manager = activeManagers.get(sessionId);
868
+ if (!manager) {
869
+ manager = {
870
+ sessionId,
871
+ workingDirectory,
872
+ currentCheckpointId: null
873
+ };
874
+ activeManagers.set(sessionId, manager);
875
+ }
876
+ return manager;
877
+ }
878
+ async function backupFile(sessionId, workingDirectory, filePath) {
879
+ const manager = getCheckpointManager(sessionId, workingDirectory);
880
+ if (!manager.currentCheckpointId) {
881
+ console.warn("[checkpoint] No active checkpoint, skipping file backup");
882
+ return null;
883
+ }
884
+ const absolutePath = resolve2(workingDirectory, filePath);
885
+ const relativePath = relative2(workingDirectory, absolutePath);
886
+ if (fileBackupQueries.hasBackup(manager.currentCheckpointId, relativePath)) {
887
+ return null;
888
+ }
889
+ let originalContent = null;
890
+ let existed = false;
891
+ if (existsSync3(absolutePath)) {
892
+ try {
893
+ originalContent = await readFile3(absolutePath, "utf-8");
894
+ existed = true;
895
+ } catch (error) {
896
+ console.warn(`[checkpoint] Failed to read file for backup: ${error.message}`);
897
+ }
898
+ }
899
+ const backup = fileBackupQueries.create({
900
+ checkpointId: manager.currentCheckpointId,
901
+ sessionId,
902
+ filePath: relativePath,
903
+ originalContent,
904
+ existed
905
+ });
906
+ return backup;
907
+ }
908
+
909
+ // src/tools/write-file.ts
210
910
  var writeFileInputSchema = z3.object({
211
911
  path: z3.string().describe("The path to the file. Can be relative to working directory or absolute."),
212
912
  mode: z3.enum(["full", "str_replace"]).describe('Write mode: "full" for complete file write, "str_replace" for targeted string replacement'),
@@ -235,8 +935,8 @@ Working directory: ${options.workingDirectory}`,
235
935
  inputSchema: writeFileInputSchema,
236
936
  execute: async ({ path, mode, content, old_string, new_string }) => {
237
937
  try {
238
- const absolutePath = isAbsolute2(path) ? path : resolve2(options.workingDirectory, path);
239
- const relativePath = relative2(options.workingDirectory, absolutePath);
938
+ const absolutePath = isAbsolute2(path) ? path : resolve3(options.workingDirectory, path);
939
+ const relativePath = relative3(options.workingDirectory, absolutePath);
240
940
  if (relativePath.startsWith("..") && !isAbsolute2(path)) {
241
941
  return {
242
942
  success: false,
@@ -250,16 +950,17 @@ Working directory: ${options.workingDirectory}`,
250
950
  error: 'Content is required for "full" mode'
251
951
  };
252
952
  }
253
- const dir = dirname(absolutePath);
254
- if (!existsSync2(dir)) {
255
- await mkdir(dir, { recursive: true });
953
+ await backupFile(options.sessionId, options.workingDirectory, absolutePath);
954
+ const dir = dirname2(absolutePath);
955
+ if (!existsSync4(dir)) {
956
+ await mkdir3(dir, { recursive: true });
256
957
  }
257
- const existed = existsSync2(absolutePath);
258
- await writeFile(absolutePath, content, "utf-8");
958
+ const existed = existsSync4(absolutePath);
959
+ await writeFile3(absolutePath, content, "utf-8");
259
960
  return {
260
961
  success: true,
261
962
  path: absolutePath,
262
- relativePath: relative2(options.workingDirectory, absolutePath),
963
+ relativePath: relative3(options.workingDirectory, absolutePath),
263
964
  mode: "full",
264
965
  action: existed ? "replaced" : "created",
265
966
  bytesWritten: Buffer.byteLength(content, "utf-8"),
@@ -272,13 +973,14 @@ Working directory: ${options.workingDirectory}`,
272
973
  error: 'Both old_string and new_string are required for "str_replace" mode'
273
974
  };
274
975
  }
275
- if (!existsSync2(absolutePath)) {
976
+ if (!existsSync4(absolutePath)) {
276
977
  return {
277
978
  success: false,
278
979
  error: `File not found: ${path}. Use "full" mode to create new files.`
279
980
  };
280
981
  }
281
- const currentContent = await readFile2(absolutePath, "utf-8");
982
+ await backupFile(options.sessionId, options.workingDirectory, absolutePath);
983
+ const currentContent = await readFile4(absolutePath, "utf-8");
282
984
  if (!currentContent.includes(old_string)) {
283
985
  const lines = currentContent.split("\n");
284
986
  const preview = lines.slice(0, 20).join("\n");
@@ -299,13 +1001,13 @@ Working directory: ${options.workingDirectory}`,
299
1001
  };
300
1002
  }
301
1003
  const newContent = currentContent.replace(old_string, new_string);
302
- await writeFile(absolutePath, newContent, "utf-8");
1004
+ await writeFile3(absolutePath, newContent, "utf-8");
303
1005
  const oldLines = old_string.split("\n").length;
304
1006
  const newLines = new_string.split("\n").length;
305
1007
  return {
306
1008
  success: true,
307
1009
  path: absolutePath,
308
- relativePath: relative2(options.workingDirectory, absolutePath),
1010
+ relativePath: relative3(options.workingDirectory, absolutePath),
309
1011
  mode: "str_replace",
310
1012
  linesRemoved: oldLines,
311
1013
  linesAdded: newLines,
@@ -316,213 +1018,19 @@ Working directory: ${options.workingDirectory}`,
316
1018
  success: false,
317
1019
  error: `Invalid mode: ${mode}`
318
1020
  };
319
- } catch (error) {
320
- return {
321
- success: false,
322
- error: error.message
323
- };
324
- }
325
- }
326
- });
327
- }
328
-
329
- // src/tools/todo.ts
330
- import { tool as tool4 } from "ai";
331
- import { z as z4 } from "zod";
332
-
333
- // src/db/index.ts
334
- import Database from "better-sqlite3";
335
- import { drizzle } from "drizzle-orm/better-sqlite3";
336
- import { eq, desc, and, sql } from "drizzle-orm";
337
- import { nanoid } from "nanoid";
338
-
339
- // src/db/schema.ts
340
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
341
- var sessions = sqliteTable("sessions", {
342
- id: text("id").primaryKey(),
343
- name: text("name"),
344
- workingDirectory: text("working_directory").notNull(),
345
- model: text("model").notNull(),
346
- status: text("status", { enum: ["active", "waiting", "completed", "error"] }).notNull().default("active"),
347
- config: text("config", { mode: "json" }).$type(),
348
- createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
349
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
350
- });
351
- var messages = sqliteTable("messages", {
352
- id: text("id").primaryKey(),
353
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
354
- // Store the entire ModelMessage as JSON (role + content)
355
- modelMessage: text("model_message", { mode: "json" }).$type().notNull(),
356
- // Sequence number within session to maintain exact ordering
357
- sequence: integer("sequence").notNull().default(0),
358
- createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
359
- });
360
- var toolExecutions = sqliteTable("tool_executions", {
361
- id: text("id").primaryKey(),
362
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
363
- messageId: text("message_id").references(() => messages.id, { onDelete: "cascade" }),
364
- toolName: text("tool_name").notNull(),
365
- toolCallId: text("tool_call_id").notNull(),
366
- input: text("input", { mode: "json" }),
367
- output: text("output", { mode: "json" }),
368
- status: text("status", { enum: ["pending", "approved", "rejected", "completed", "error"] }).notNull().default("pending"),
369
- requiresApproval: integer("requires_approval", { mode: "boolean" }).notNull().default(false),
370
- error: text("error"),
371
- startedAt: integer("started_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
372
- completedAt: integer("completed_at", { mode: "timestamp" })
373
- });
374
- var todoItems = sqliteTable("todo_items", {
375
- id: text("id").primaryKey(),
376
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
377
- content: text("content").notNull(),
378
- status: text("status", { enum: ["pending", "in_progress", "completed", "cancelled"] }).notNull().default("pending"),
379
- order: integer("order").notNull().default(0),
380
- createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
381
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
382
- });
383
- var loadedSkills = sqliteTable("loaded_skills", {
384
- id: text("id").primaryKey(),
385
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
386
- skillName: text("skill_name").notNull(),
387
- loadedAt: integer("loaded_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
388
- });
389
- var terminals = sqliteTable("terminals", {
390
- id: text("id").primaryKey(),
391
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
392
- name: text("name"),
393
- // Optional friendly name (e.g., "dev-server")
394
- command: text("command").notNull(),
395
- // The command that was run
396
- cwd: text("cwd").notNull(),
397
- // Working directory
398
- pid: integer("pid"),
399
- // Process ID (null if not running)
400
- status: text("status", { enum: ["running", "stopped", "error"] }).notNull().default("running"),
401
- exitCode: integer("exit_code"),
402
- // Exit code if stopped
403
- error: text("error"),
404
- // Error message if status is 'error'
405
- createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
406
- stoppedAt: integer("stopped_at", { mode: "timestamp" })
407
- });
408
-
409
- // src/db/index.ts
410
- var db = null;
411
- function getDb() {
412
- if (!db) {
413
- throw new Error("Database not initialized. Call initDatabase first.");
414
- }
415
- return db;
1021
+ } catch (error) {
1022
+ return {
1023
+ success: false,
1024
+ error: error.message
1025
+ };
1026
+ }
1027
+ }
1028
+ });
416
1029
  }
417
- var todoQueries = {
418
- create(data) {
419
- const id = nanoid();
420
- const now = /* @__PURE__ */ new Date();
421
- const result = getDb().insert(todoItems).values({
422
- id,
423
- ...data,
424
- createdAt: now,
425
- updatedAt: now
426
- }).returning().get();
427
- return result;
428
- },
429
- createMany(sessionId, items) {
430
- const now = /* @__PURE__ */ new Date();
431
- const values = items.map((item, index) => ({
432
- id: nanoid(),
433
- sessionId,
434
- content: item.content,
435
- order: item.order ?? index,
436
- createdAt: now,
437
- updatedAt: now
438
- }));
439
- return getDb().insert(todoItems).values(values).returning().all();
440
- },
441
- getBySession(sessionId) {
442
- return getDb().select().from(todoItems).where(eq(todoItems.sessionId, sessionId)).orderBy(todoItems.order).all();
443
- },
444
- updateStatus(id, status) {
445
- return getDb().update(todoItems).set({ status, updatedAt: /* @__PURE__ */ new Date() }).where(eq(todoItems.id, id)).returning().get();
446
- },
447
- delete(id) {
448
- const result = getDb().delete(todoItems).where(eq(todoItems.id, id)).run();
449
- return result.changes > 0;
450
- },
451
- clearSession(sessionId) {
452
- const result = getDb().delete(todoItems).where(eq(todoItems.sessionId, sessionId)).run();
453
- return result.changes;
454
- }
455
- };
456
- var skillQueries = {
457
- load(sessionId, skillName) {
458
- const id = nanoid();
459
- const result = getDb().insert(loadedSkills).values({
460
- id,
461
- sessionId,
462
- skillName,
463
- loadedAt: /* @__PURE__ */ new Date()
464
- }).returning().get();
465
- return result;
466
- },
467
- getBySession(sessionId) {
468
- return getDb().select().from(loadedSkills).where(eq(loadedSkills.sessionId, sessionId)).orderBy(loadedSkills.loadedAt).all();
469
- },
470
- isLoaded(sessionId, skillName) {
471
- const result = getDb().select().from(loadedSkills).where(
472
- and(
473
- eq(loadedSkills.sessionId, sessionId),
474
- eq(loadedSkills.skillName, skillName)
475
- )
476
- ).get();
477
- return !!result;
478
- }
479
- };
480
- var terminalQueries = {
481
- create(data) {
482
- const id = nanoid();
483
- const result = getDb().insert(terminals).values({
484
- id,
485
- ...data,
486
- createdAt: /* @__PURE__ */ new Date()
487
- }).returning().get();
488
- return result;
489
- },
490
- getById(id) {
491
- return getDb().select().from(terminals).where(eq(terminals.id, id)).get();
492
- },
493
- getBySession(sessionId) {
494
- return getDb().select().from(terminals).where(eq(terminals.sessionId, sessionId)).orderBy(desc(terminals.createdAt)).all();
495
- },
496
- getRunning(sessionId) {
497
- return getDb().select().from(terminals).where(
498
- and(
499
- eq(terminals.sessionId, sessionId),
500
- eq(terminals.status, "running")
501
- )
502
- ).all();
503
- },
504
- updateStatus(id, status, exitCode, error) {
505
- return getDb().update(terminals).set({
506
- status,
507
- exitCode,
508
- error,
509
- stoppedAt: status !== "running" ? /* @__PURE__ */ new Date() : void 0
510
- }).where(eq(terminals.id, id)).returning().get();
511
- },
512
- updatePid(id, pid) {
513
- return getDb().update(terminals).set({ pid }).where(eq(terminals.id, id)).returning().get();
514
- },
515
- delete(id) {
516
- const result = getDb().delete(terminals).where(eq(terminals.id, id)).run();
517
- return result.changes > 0;
518
- },
519
- deleteBySession(sessionId) {
520
- const result = getDb().delete(terminals).where(eq(terminals.sessionId, sessionId)).run();
521
- return result.changes;
522
- }
523
- };
524
1030
 
525
1031
  // src/tools/todo.ts
1032
+ import { tool as tool4 } from "ai";
1033
+ import { z as z4 } from "zod";
526
1034
  var todoInputSchema = z4.object({
527
1035
  action: z4.enum(["add", "list", "mark", "clear"]).describe("The action to perform on the todo list"),
528
1036
  items: z4.array(
@@ -650,9 +1158,9 @@ import { tool as tool5 } from "ai";
650
1158
  import { z as z6 } from "zod";
651
1159
 
652
1160
  // src/skills/index.ts
653
- import { readFile as readFile3, readdir } from "fs/promises";
654
- import { resolve as resolve3, basename, extname } from "path";
655
- import { existsSync as existsSync3 } from "fs";
1161
+ import { readFile as readFile5, readdir } from "fs/promises";
1162
+ import { resolve as resolve4, basename, extname } from "path";
1163
+ import { existsSync as existsSync5 } from "fs";
656
1164
 
657
1165
  // src/config/types.ts
658
1166
  import { z as z5 } from "zod";
@@ -738,15 +1246,15 @@ function getSkillNameFromPath(filePath) {
738
1246
  return basename(filePath, extname(filePath)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
739
1247
  }
740
1248
  async function loadSkillsFromDirectory(directory) {
741
- if (!existsSync3(directory)) {
1249
+ if (!existsSync5(directory)) {
742
1250
  return [];
743
1251
  }
744
1252
  const skills = [];
745
1253
  const files = await readdir(directory);
746
1254
  for (const file of files) {
747
1255
  if (!file.endsWith(".md")) continue;
748
- const filePath = resolve3(directory, file);
749
- const content = await readFile3(filePath, "utf-8");
1256
+ const filePath = resolve4(directory, file);
1257
+ const content = await readFile5(filePath, "utf-8");
750
1258
  const parsed = parseSkillFrontmatter(content);
751
1259
  if (parsed) {
752
1260
  skills.push({
@@ -788,7 +1296,7 @@ async function loadSkillContent(skillName, directories) {
788
1296
  if (!skill) {
789
1297
  return null;
790
1298
  }
791
- const content = await readFile3(skill.filePath, "utf-8");
1299
+ const content = await readFile5(skill.filePath, "utf-8");
792
1300
  const parsed = parseSkillFrontmatter(content);
793
1301
  return {
794
1302
  ...skill,
@@ -886,460 +1394,21 @@ Once loaded, a skill's content will be available in the conversation context.`,
886
1394
  });
887
1395
  }
888
1396
 
889
- // src/tools/terminal.ts
890
- import { tool as tool6 } from "ai";
891
- import { z as z7 } from "zod";
892
-
893
- // src/terminal/manager.ts
894
- import { spawn } from "child_process";
895
- import { EventEmitter } from "events";
896
- var LogBuffer = class {
897
- buffer = [];
898
- maxSize;
899
- totalBytes = 0;
900
- maxBytes;
901
- constructor(maxBytes = 50 * 1024) {
902
- this.maxBytes = maxBytes;
903
- this.maxSize = 1e3;
904
- }
905
- append(data) {
906
- const lines = data.split("\n");
907
- for (const line of lines) {
908
- if (line) {
909
- this.buffer.push(line);
910
- this.totalBytes += line.length;
911
- }
912
- }
913
- while (this.totalBytes > this.maxBytes && this.buffer.length > 1) {
914
- const removed = this.buffer.shift();
915
- if (removed) {
916
- this.totalBytes -= removed.length;
917
- }
918
- }
919
- while (this.buffer.length > this.maxSize) {
920
- const removed = this.buffer.shift();
921
- if (removed) {
922
- this.totalBytes -= removed.length;
923
- }
924
- }
925
- }
926
- getAll() {
927
- return this.buffer.join("\n");
928
- }
929
- getTail(lines) {
930
- const start = Math.max(0, this.buffer.length - lines);
931
- return this.buffer.slice(start).join("\n");
932
- }
933
- clear() {
934
- this.buffer = [];
935
- this.totalBytes = 0;
936
- }
937
- get lineCount() {
938
- return this.buffer.length;
939
- }
940
- };
941
- var TerminalManager = class _TerminalManager extends EventEmitter {
942
- processes = /* @__PURE__ */ new Map();
943
- static instance = null;
944
- constructor() {
945
- super();
946
- }
947
- static getInstance() {
948
- if (!_TerminalManager.instance) {
949
- _TerminalManager.instance = new _TerminalManager();
950
- }
951
- return _TerminalManager.instance;
952
- }
953
- /**
954
- * Spawn a new background process
955
- */
956
- spawn(options) {
957
- const { sessionId, command, cwd, name, env } = options;
958
- const parts = this.parseCommand(command);
959
- const executable = parts[0];
960
- const args = parts.slice(1);
961
- const terminal = terminalQueries.create({
962
- sessionId,
963
- name: name || null,
964
- command,
965
- cwd: cwd || process.cwd(),
966
- status: "running"
967
- });
968
- const proc = spawn(executable, args, {
969
- cwd: cwd || process.cwd(),
970
- shell: true,
971
- stdio: ["pipe", "pipe", "pipe"],
972
- env: { ...process.env, ...env },
973
- detached: false
974
- });
975
- if (proc.pid) {
976
- terminalQueries.updatePid(terminal.id, proc.pid);
977
- }
978
- const logs = new LogBuffer();
979
- proc.stdout?.on("data", (data) => {
980
- const text2 = data.toString();
981
- logs.append(text2);
982
- this.emit("stdout", { terminalId: terminal.id, data: text2 });
983
- });
984
- proc.stderr?.on("data", (data) => {
985
- const text2 = data.toString();
986
- logs.append(`[stderr] ${text2}`);
987
- this.emit("stderr", { terminalId: terminal.id, data: text2 });
988
- });
989
- proc.on("exit", (code, signal) => {
990
- const exitCode = code ?? (signal ? 128 : 0);
991
- terminalQueries.updateStatus(terminal.id, "stopped", exitCode);
992
- this.emit("exit", { terminalId: terminal.id, code: exitCode, signal });
993
- const managed2 = this.processes.get(terminal.id);
994
- if (managed2) {
995
- managed2.terminal = { ...managed2.terminal, status: "stopped", exitCode };
996
- }
997
- });
998
- proc.on("error", (err) => {
999
- terminalQueries.updateStatus(terminal.id, "error", void 0, err.message);
1000
- this.emit("error", { terminalId: terminal.id, error: err.message });
1001
- const managed2 = this.processes.get(terminal.id);
1002
- if (managed2) {
1003
- managed2.terminal = { ...managed2.terminal, status: "error", error: err.message };
1004
- }
1005
- });
1006
- const managed = {
1007
- id: terminal.id,
1008
- process: proc,
1009
- logs,
1010
- terminal: { ...terminal, pid: proc.pid ?? null }
1011
- };
1012
- this.processes.set(terminal.id, managed);
1013
- return this.toTerminalInfo(managed.terminal);
1014
- }
1015
- /**
1016
- * Get logs from a terminal
1017
- */
1018
- getLogs(terminalId, tail) {
1019
- const managed = this.processes.get(terminalId);
1020
- if (!managed) {
1021
- return null;
1022
- }
1023
- return {
1024
- logs: tail ? managed.logs.getTail(tail) : managed.logs.getAll(),
1025
- lineCount: managed.logs.lineCount
1026
- };
1027
- }
1028
- /**
1029
- * Get terminal status
1030
- */
1031
- getStatus(terminalId) {
1032
- const managed = this.processes.get(terminalId);
1033
- if (managed) {
1034
- if (managed.process.exitCode !== null) {
1035
- managed.terminal = {
1036
- ...managed.terminal,
1037
- status: "stopped",
1038
- exitCode: managed.process.exitCode
1039
- };
1040
- }
1041
- return this.toTerminalInfo(managed.terminal);
1042
- }
1043
- const terminal = terminalQueries.getById(terminalId);
1044
- if (terminal) {
1045
- return this.toTerminalInfo(terminal);
1046
- }
1047
- return null;
1048
- }
1049
- /**
1050
- * Kill a terminal process
1051
- */
1052
- kill(terminalId, signal = "SIGTERM") {
1053
- const managed = this.processes.get(terminalId);
1054
- if (!managed) {
1055
- return false;
1056
- }
1057
- try {
1058
- managed.process.kill(signal);
1059
- return true;
1060
- } catch (err) {
1061
- console.error(`Failed to kill terminal ${terminalId}:`, err);
1062
- return false;
1063
- }
1064
- }
1065
- /**
1066
- * Write to a terminal's stdin
1067
- */
1068
- write(terminalId, input) {
1069
- const managed = this.processes.get(terminalId);
1070
- if (!managed || !managed.process.stdin) {
1071
- return false;
1072
- }
1073
- try {
1074
- managed.process.stdin.write(input);
1075
- return true;
1076
- } catch (err) {
1077
- console.error(`Failed to write to terminal ${terminalId}:`, err);
1078
- return false;
1079
- }
1080
- }
1081
- /**
1082
- * List all terminals for a session
1083
- */
1084
- list(sessionId) {
1085
- const terminals2 = terminalQueries.getBySession(sessionId);
1086
- return terminals2.map((t) => {
1087
- const managed = this.processes.get(t.id);
1088
- if (managed) {
1089
- return this.toTerminalInfo(managed.terminal);
1090
- }
1091
- return this.toTerminalInfo(t);
1092
- });
1093
- }
1094
- /**
1095
- * Get all running terminals for a session
1096
- */
1097
- getRunning(sessionId) {
1098
- return this.list(sessionId).filter((t) => t.status === "running");
1099
- }
1100
- /**
1101
- * Kill all terminals for a session (cleanup)
1102
- */
1103
- killAll(sessionId) {
1104
- let killed = 0;
1105
- for (const [id, managed] of this.processes) {
1106
- if (managed.terminal.sessionId === sessionId) {
1107
- if (this.kill(id)) {
1108
- killed++;
1109
- }
1110
- }
1111
- }
1112
- return killed;
1113
- }
1114
- /**
1115
- * Clean up stopped terminals from memory (keep DB records)
1116
- */
1117
- cleanup(sessionId) {
1118
- let cleaned = 0;
1119
- for (const [id, managed] of this.processes) {
1120
- if (sessionId && managed.terminal.sessionId !== sessionId) {
1121
- continue;
1122
- }
1123
- if (managed.terminal.status !== "running") {
1124
- this.processes.delete(id);
1125
- cleaned++;
1126
- }
1127
- }
1128
- return cleaned;
1129
- }
1130
- /**
1131
- * Parse a command string into executable and arguments
1132
- */
1133
- parseCommand(command) {
1134
- const parts = [];
1135
- let current = "";
1136
- let inQuote = false;
1137
- let quoteChar = "";
1138
- for (const char of command) {
1139
- if ((char === '"' || char === "'") && !inQuote) {
1140
- inQuote = true;
1141
- quoteChar = char;
1142
- } else if (char === quoteChar && inQuote) {
1143
- inQuote = false;
1144
- quoteChar = "";
1145
- } else if (char === " " && !inQuote) {
1146
- if (current) {
1147
- parts.push(current);
1148
- current = "";
1149
- }
1150
- } else {
1151
- current += char;
1152
- }
1153
- }
1154
- if (current) {
1155
- parts.push(current);
1156
- }
1157
- return parts.length > 0 ? parts : [command];
1158
- }
1159
- toTerminalInfo(terminal) {
1160
- return {
1161
- id: terminal.id,
1162
- name: terminal.name,
1163
- command: terminal.command,
1164
- cwd: terminal.cwd,
1165
- pid: terminal.pid,
1166
- status: terminal.status,
1167
- exitCode: terminal.exitCode,
1168
- error: terminal.error,
1169
- createdAt: terminal.createdAt,
1170
- stoppedAt: terminal.stoppedAt
1171
- };
1172
- }
1173
- };
1174
- function getTerminalManager() {
1175
- return TerminalManager.getInstance();
1176
- }
1177
-
1178
- // src/tools/terminal.ts
1179
- var TerminalInputSchema = z7.object({
1180
- action: z7.enum(["spawn", "logs", "status", "kill", "write", "list"]).describe(
1181
- "The action to perform: spawn (start process), logs (get output), status (check if running), kill (stop process), write (send stdin), list (show all)"
1182
- ),
1183
- // For spawn
1184
- command: z7.string().optional().describe('For spawn: The command to run (e.g., "npm run dev")'),
1185
- cwd: z7.string().optional().describe("For spawn: Working directory for the command"),
1186
- name: z7.string().optional().describe('For spawn: Optional friendly name (e.g., "dev-server")'),
1187
- // For logs, status, kill, write
1188
- terminalId: z7.string().optional().describe("For logs/status/kill/write: The terminal ID"),
1189
- tail: z7.number().optional().describe("For logs: Number of lines to return from the end"),
1190
- // For kill
1191
- signal: z7.enum(["SIGTERM", "SIGKILL"]).optional().describe("For kill: Signal to send (default: SIGTERM)"),
1192
- // For write
1193
- input: z7.string().optional().describe("For write: The input to send to stdin")
1194
- });
1195
- function createTerminalTool(options) {
1196
- const { sessionId, workingDirectory } = options;
1197
- return tool6({
1198
- description: `Manage background terminal processes. Use this for long-running commands like dev servers, watchers, or any process that shouldn't block.
1199
-
1200
- Actions:
1201
- - spawn: Start a new background process. Requires 'command'. Returns terminal ID.
1202
- - logs: Get output from a terminal. Requires 'terminalId'. Optional 'tail' for recent lines.
1203
- - status: Check if a terminal is still running. Requires 'terminalId'.
1204
- - kill: Stop a terminal process. Requires 'terminalId'. Optional 'signal'.
1205
- - write: Send input to a terminal's stdin. Requires 'terminalId' and 'input'.
1206
- - list: Show all terminals for this session. No other params needed.
1207
-
1208
- Example workflow:
1209
- 1. spawn with command="npm run dev", name="dev-server" \u2192 { id: "abc123", status: "running" }
1210
- 2. logs with terminalId="abc123", tail=10 \u2192 "Ready on http://localhost:3000"
1211
- 3. kill with terminalId="abc123" \u2192 { success: true }`,
1212
- inputSchema: TerminalInputSchema,
1213
- execute: async (input) => {
1214
- const manager = getTerminalManager();
1215
- switch (input.action) {
1216
- case "spawn": {
1217
- if (!input.command) {
1218
- return { success: false, error: 'spawn requires a "command" parameter' };
1219
- }
1220
- const terminal = manager.spawn({
1221
- sessionId,
1222
- command: input.command,
1223
- cwd: input.cwd || workingDirectory,
1224
- name: input.name
1225
- });
1226
- return {
1227
- success: true,
1228
- terminal: formatTerminal(terminal),
1229
- message: `Started "${input.command}" with terminal ID: ${terminal.id}`
1230
- };
1231
- }
1232
- case "logs": {
1233
- if (!input.terminalId) {
1234
- return { success: false, error: 'logs requires a "terminalId" parameter' };
1235
- }
1236
- const result = manager.getLogs(input.terminalId, input.tail);
1237
- if (!result) {
1238
- return {
1239
- success: false,
1240
- error: `Terminal not found: ${input.terminalId}`
1241
- };
1242
- }
1243
- return {
1244
- success: true,
1245
- terminalId: input.terminalId,
1246
- logs: result.logs,
1247
- lineCount: result.lineCount
1248
- };
1249
- }
1250
- case "status": {
1251
- if (!input.terminalId) {
1252
- return { success: false, error: 'status requires a "terminalId" parameter' };
1253
- }
1254
- const status = manager.getStatus(input.terminalId);
1255
- if (!status) {
1256
- return {
1257
- success: false,
1258
- error: `Terminal not found: ${input.terminalId}`
1259
- };
1260
- }
1261
- return {
1262
- success: true,
1263
- terminal: formatTerminal(status)
1264
- };
1265
- }
1266
- case "kill": {
1267
- if (!input.terminalId) {
1268
- return { success: false, error: 'kill requires a "terminalId" parameter' };
1269
- }
1270
- const success = manager.kill(input.terminalId, input.signal);
1271
- if (!success) {
1272
- return {
1273
- success: false,
1274
- error: `Failed to kill terminal: ${input.terminalId}`
1275
- };
1276
- }
1277
- return {
1278
- success: true,
1279
- message: `Sent ${input.signal || "SIGTERM"} to terminal ${input.terminalId}`
1280
- };
1281
- }
1282
- case "write": {
1283
- if (!input.terminalId) {
1284
- return { success: false, error: 'write requires a "terminalId" parameter' };
1285
- }
1286
- if (!input.input) {
1287
- return { success: false, error: 'write requires an "input" parameter' };
1288
- }
1289
- const success = manager.write(input.terminalId, input.input);
1290
- if (!success) {
1291
- return {
1292
- success: false,
1293
- error: `Failed to write to terminal: ${input.terminalId}`
1294
- };
1295
- }
1296
- return {
1297
- success: true,
1298
- message: `Sent input to terminal ${input.terminalId}`
1299
- };
1300
- }
1301
- case "list": {
1302
- const terminals2 = manager.list(sessionId);
1303
- return {
1304
- success: true,
1305
- terminals: terminals2.map(formatTerminal),
1306
- count: terminals2.length,
1307
- running: terminals2.filter((t) => t.status === "running").length
1308
- };
1309
- }
1310
- default:
1311
- return { success: false, error: `Unknown action: ${input.action}` };
1312
- }
1313
- }
1314
- });
1315
- }
1316
- function formatTerminal(t) {
1317
- return {
1318
- id: t.id,
1319
- name: t.name,
1320
- command: t.command,
1321
- cwd: t.cwd,
1322
- pid: t.pid,
1323
- status: t.status,
1324
- exitCode: t.exitCode,
1325
- error: t.error,
1326
- createdAt: t.createdAt.toISOString(),
1327
- stoppedAt: t.stoppedAt?.toISOString() || null
1328
- };
1329
- }
1330
-
1331
1397
  // src/tools/index.ts
1332
1398
  function createTools(options) {
1333
1399
  return {
1334
1400
  bash: createBashTool({
1335
1401
  workingDirectory: options.workingDirectory,
1336
- onOutput: options.onBashOutput
1402
+ sessionId: options.sessionId,
1403
+ onOutput: options.onBashOutput,
1404
+ onProgress: options.onBashProgress
1337
1405
  }),
1338
1406
  read_file: createReadFileTool({
1339
1407
  workingDirectory: options.workingDirectory
1340
1408
  }),
1341
1409
  write_file: createWriteFileTool({
1342
- workingDirectory: options.workingDirectory
1410
+ workingDirectory: options.workingDirectory,
1411
+ sessionId: options.sessionId
1343
1412
  }),
1344
1413
  todo: createTodoTool({
1345
1414
  sessionId: options.sessionId
@@ -1347,10 +1416,6 @@ function createTools(options) {
1347
1416
  load_skill: createLoadSkillTool({
1348
1417
  sessionId: options.sessionId,
1349
1418
  skillsDirectories: options.skillsDirectories
1350
- }),
1351
- terminal: createTerminalTool({
1352
- sessionId: options.sessionId,
1353
- workingDirectory: options.workingDirectory
1354
1419
  })
1355
1420
  };
1356
1421
  }
@@ -1358,7 +1423,6 @@ export {
1358
1423
  createBashTool,
1359
1424
  createLoadSkillTool,
1360
1425
  createReadFileTool,
1361
- createTerminalTool,
1362
1426
  createTodoTool,
1363
1427
  createTools,
1364
1428
  createWriteFileTool