sparkecoder 0.1.3 → 0.1.4

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,254 @@ 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
+ console.log(`[tmux] Available: ${stdout.trim()}`);
41
+ return true;
42
+ } catch (error) {
43
+ tmuxAvailableCache = false;
44
+ console.log(`[tmux] Not available: ${error instanceof Error ? error.message : "unknown error"}`);
45
+ return false;
46
+ }
47
+ }
48
+ function generateTerminalId() {
49
+ return "t" + nanoid(9);
50
+ }
51
+ function getSessionName(terminalId) {
52
+ return `${SESSION_PREFIX}${terminalId}`;
53
+ }
54
+ function getLogDir(terminalId, workingDirectory, sessionId) {
55
+ if (sessionId) {
56
+ return join(workingDirectory, LOG_BASE_DIR, sessionId, "terminals", terminalId);
57
+ }
58
+ return join(workingDirectory, ".sparkecoder/terminals", terminalId);
59
+ }
60
+ function shellEscape(str) {
61
+ return `'${str.replace(/'/g, "'\\''")}'`;
62
+ }
63
+ async function initLogDir(terminalId, meta, workingDirectory) {
64
+ const logDir = getLogDir(terminalId, workingDirectory, meta.sessionId);
65
+ await mkdir(logDir, { recursive: true });
66
+ await writeFile(join(logDir, "meta.json"), JSON.stringify(meta, null, 2));
67
+ await writeFile(join(logDir, "output.log"), "");
68
+ return logDir;
69
+ }
70
+ async function pollUntil(condition, options) {
71
+ const { timeout, interval = 100 } = options;
72
+ const startTime = Date.now();
73
+ while (Date.now() - startTime < timeout) {
74
+ if (await condition()) {
75
+ return true;
76
+ }
77
+ await new Promise((r) => setTimeout(r, interval));
78
+ }
79
+ return false;
80
+ }
81
+ async function runSync(command, workingDirectory, options) {
82
+ if (!options) {
83
+ throw new Error("runSync: options parameter is required (must include sessionId)");
84
+ }
85
+ const id = options.terminalId || generateTerminalId();
86
+ const session = getSessionName(id);
87
+ const logDir = await initLogDir(id, {
88
+ id,
89
+ command,
90
+ cwd: workingDirectory,
91
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
92
+ sessionId: options.sessionId,
93
+ background: false
94
+ }, workingDirectory);
95
+ const logFile = join(logDir, "output.log");
96
+ const exitCodeFile = join(logDir, "exit_code");
97
+ const timeout = options.timeout || 12e4;
98
+ try {
99
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}; echo $? > ${shellEscape(exitCodeFile)}`;
100
+ await execAsync(
101
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
102
+ { timeout: 5e3 }
103
+ );
104
+ try {
105
+ await execAsync(
106
+ `tmux pipe-pane -t ${session} -o 'cat >> ${shellEscape(logFile)}'`,
107
+ { timeout: 1e3 }
108
+ );
109
+ } catch {
110
+ }
111
+ const completed = await pollUntil(
112
+ async () => {
113
+ try {
114
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
115
+ return false;
116
+ } catch {
117
+ return true;
118
+ }
119
+ },
120
+ { timeout, interval: 100 }
121
+ );
122
+ if (!completed) {
123
+ try {
124
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
125
+ } catch {
126
+ }
127
+ let output2 = "";
128
+ try {
129
+ output2 = await readFile(logFile, "utf-8");
130
+ } catch {
131
+ }
132
+ return {
133
+ id,
134
+ output: output2.trim(),
135
+ exitCode: 124,
136
+ // Standard timeout exit code
137
+ status: "error"
138
+ };
139
+ }
140
+ await new Promise((r) => setTimeout(r, 50));
141
+ let output = "";
142
+ try {
143
+ output = await readFile(logFile, "utf-8");
144
+ } catch {
145
+ }
146
+ let exitCode = 0;
147
+ try {
148
+ if (existsSync(exitCodeFile)) {
149
+ const exitCodeStr = await readFile(exitCodeFile, "utf-8");
150
+ exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
151
+ }
152
+ } catch {
153
+ }
154
+ return {
155
+ id,
156
+ output: output.trim(),
157
+ exitCode,
158
+ status: "completed"
159
+ };
160
+ } catch (error) {
161
+ try {
162
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
163
+ } catch {
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+ async function runBackground(command, workingDirectory, options) {
169
+ if (!options) {
170
+ throw new Error("runBackground: options parameter is required (must include sessionId)");
171
+ }
172
+ const id = options.terminalId || generateTerminalId();
173
+ const session = getSessionName(id);
174
+ const logDir = await initLogDir(id, {
175
+ id,
176
+ command,
177
+ cwd: workingDirectory,
178
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
179
+ sessionId: options.sessionId,
180
+ background: true
181
+ }, workingDirectory);
182
+ const logFile = join(logDir, "output.log");
183
+ const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}`;
184
+ await execAsync(
185
+ `tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
186
+ { timeout: 5e3 }
187
+ );
188
+ return {
189
+ id,
190
+ output: "",
191
+ exitCode: 0,
192
+ status: "running"
193
+ };
194
+ }
195
+ async function getLogs(terminalId, workingDirectory, options = {}) {
196
+ const session = getSessionName(terminalId);
197
+ const logDir = getLogDir(terminalId, workingDirectory, options.sessionId);
198
+ const logFile = join(logDir, "output.log");
199
+ let isRunning = false;
200
+ try {
201
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
202
+ isRunning = true;
203
+ } catch {
204
+ }
205
+ if (isRunning) {
206
+ try {
207
+ const lines = options.tail || 1e3;
208
+ const { stdout } = await execAsync(
209
+ `tmux capture-pane -t ${session} -p -S -${lines}`,
210
+ { timeout: 5e3, maxBuffer: 10 * 1024 * 1024 }
211
+ );
212
+ return { output: stdout.trim(), status: "running" };
213
+ } catch {
214
+ }
215
+ }
216
+ try {
217
+ let output = await readFile(logFile, "utf-8");
218
+ if (options.tail) {
219
+ const lines = output.split("\n");
220
+ output = lines.slice(-options.tail).join("\n");
221
+ }
222
+ return { output: output.trim(), status: isRunning ? "running" : "stopped" };
223
+ } catch {
224
+ return { output: "", status: "unknown" };
225
+ }
226
+ }
227
+ async function killTerminal(terminalId) {
228
+ const session = getSessionName(terminalId);
229
+ try {
230
+ await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+ async function sendInput(terminalId, input, options = {}) {
237
+ const session = getSessionName(terminalId);
238
+ const { pressEnter = true } = options;
239
+ try {
240
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
241
+ await execAsync(
242
+ `tmux send-keys -t ${session} -l ${shellEscape(input)}`,
243
+ { timeout: 1e3 }
244
+ );
245
+ if (pressEnter) {
246
+ await execAsync(
247
+ `tmux send-keys -t ${session} Enter`,
248
+ { timeout: 1e3 }
249
+ );
250
+ }
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ async function sendKey(terminalId, key) {
257
+ const session = getSessionName(terminalId);
258
+ try {
259
+ await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
260
+ await execAsync(`tmux send-keys -t ${session} ${key}`, { timeout: 1e3 });
261
+ return true;
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ // src/tools/bash.ts
268
+ var execAsync2 = promisify2(exec2);
269
+ var COMMAND_TIMEOUT = 12e4;
25
270
  var MAX_OUTPUT_CHARS2 = 1e4;
26
271
  var BLOCKED_COMMANDS = [
27
272
  "rm -rf /",
@@ -38,66 +283,226 @@ function isBlockedCommand(command) {
38
283
  );
39
284
  }
40
285
  var bashInputSchema = z.object({
41
- command: z.string().describe("The bash command to execute. Can be a single command or a pipeline.")
286
+ command: z.string().optional().describe("The command to execute. Required for running new commands."),
287
+ background: z.boolean().default(false).describe("Run the command in background mode (for dev servers, watchers). Returns immediately with terminal ID."),
288
+ id: z.string().optional().describe("Terminal ID. Use to get logs from, send input to, or kill an existing terminal."),
289
+ kill: z.boolean().optional().describe("Kill the terminal with the given ID."),
290
+ tail: z.number().optional().describe("Number of lines to return from the end of output (for logs)."),
291
+ input: z.string().optional().describe("Send text input to an interactive terminal (requires id). Used for responding to prompts."),
292
+ 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
293
  });
294
+ var useTmux = null;
295
+ async function shouldUseTmux() {
296
+ if (useTmux === null) {
297
+ useTmux = await isTmuxAvailable();
298
+ if (!useTmux) {
299
+ console.warn("[bash] tmux not available, using fallback exec mode");
300
+ }
301
+ }
302
+ return useTmux;
303
+ }
304
+ async function execFallback(command, workingDirectory, onOutput) {
305
+ try {
306
+ const { stdout, stderr } = await execAsync2(command, {
307
+ cwd: workingDirectory,
308
+ timeout: COMMAND_TIMEOUT,
309
+ maxBuffer: 10 * 1024 * 1024
310
+ });
311
+ const output = truncateOutput(stdout + (stderr ? `
312
+ ${stderr}` : ""), MAX_OUTPUT_CHARS2);
313
+ onOutput?.(output);
314
+ return {
315
+ success: true,
316
+ output,
317
+ exitCode: 0
318
+ };
319
+ } catch (error) {
320
+ const output = truncateOutput(
321
+ (error.stdout || "") + (error.stderr ? `
322
+ ${error.stderr}` : ""),
323
+ MAX_OUTPUT_CHARS2
324
+ );
325
+ onOutput?.(output || error.message);
326
+ if (error.killed) {
327
+ return {
328
+ success: false,
329
+ error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
330
+ output,
331
+ exitCode: 124
332
+ };
333
+ }
334
+ return {
335
+ success: false,
336
+ error: error.message,
337
+ output,
338
+ exitCode: error.code ?? 1
339
+ };
340
+ }
341
+ }
43
342
  function createBashTool(options) {
44
343
  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.`,
344
+ description: `Execute commands in the terminal. Every command runs in its own session with logs saved to disk.
345
+
346
+ **Run a command (default - waits for completion):**
347
+ bash({ command: "npm install" })
348
+ bash({ command: "git status" })
349
+
350
+ **Run in background (for dev servers, watchers, or interactive commands):**
351
+ bash({ command: "npm run dev", background: true })
352
+ \u2192 Returns { id: "abc123" } - save this ID
353
+
354
+ **Check on a background process:**
355
+ bash({ id: "abc123" })
356
+ bash({ id: "abc123", tail: 50 }) // last 50 lines only
357
+
358
+ **Stop a background process:**
359
+ bash({ id: "abc123", kill: true })
360
+
361
+ **Respond to interactive prompts (for yes/no questions, etc.):**
362
+ bash({ id: "abc123", key: "y" }) // send 'y' for yes
363
+ bash({ id: "abc123", key: "n" }) // send 'n' for no
364
+ bash({ id: "abc123", key: "Enter" }) // press Enter
365
+ bash({ id: "abc123", input: "my text" }) // send text input
366
+
367
+ **IMPORTANT for interactive commands:**
368
+ - Use --yes, -y, or similar flags to avoid prompts when available
369
+ - For create-next-app: add --yes to accept defaults
370
+ - For npm: add --yes or -y to skip confirmation
371
+ - If prompts are unavoidable, run in background mode and use input/key to respond
372
+
373
+ Logs are saved to .sparkecoder/terminals/{id}/output.log`,
49
374
  inputSchema: bashInputSchema,
50
- execute: async ({ command }) => {
375
+ execute: async (inputArgs) => {
376
+ const { command, background, id, kill, tail, input: textInput, key } = inputArgs;
377
+ if (id) {
378
+ if (kill) {
379
+ const success = await killTerminal(id);
380
+ return {
381
+ success,
382
+ id,
383
+ status: success ? "stopped" : "not_found",
384
+ message: success ? `Terminal ${id} stopped` : `Terminal ${id} not found or already stopped`
385
+ };
386
+ }
387
+ if (textInput !== void 0) {
388
+ const success = await sendInput(id, textInput, { pressEnter: true });
389
+ if (!success) {
390
+ return {
391
+ success: false,
392
+ id,
393
+ error: `Terminal ${id} not found or not running`
394
+ };
395
+ }
396
+ await new Promise((r) => setTimeout(r, 300));
397
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
398
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
399
+ return {
400
+ success: true,
401
+ id,
402
+ output: truncatedOutput2,
403
+ status: status2,
404
+ message: `Sent input "${textInput}" to terminal`
405
+ };
406
+ }
407
+ if (key) {
408
+ const success = await sendKey(id, key);
409
+ if (!success) {
410
+ return {
411
+ success: false,
412
+ id,
413
+ error: `Terminal ${id} not found or not running`
414
+ };
415
+ }
416
+ await new Promise((r) => setTimeout(r, 300));
417
+ const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
418
+ const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
419
+ return {
420
+ success: true,
421
+ id,
422
+ output: truncatedOutput2,
423
+ status: status2,
424
+ message: `Sent key "${key}" to terminal`
425
+ };
426
+ }
427
+ const { output, status } = await getLogs(id, options.workingDirectory, { tail, sessionId: options.sessionId });
428
+ const truncatedOutput = truncateOutput(output, MAX_OUTPUT_CHARS2);
429
+ return {
430
+ success: true,
431
+ id,
432
+ output: truncatedOutput,
433
+ status
434
+ };
435
+ }
436
+ if (!command) {
437
+ return {
438
+ success: false,
439
+ error: 'Either "command" (to run a new command) or "id" (to check/kill/send input) is required'
440
+ };
441
+ }
51
442
  if (isBlockedCommand(command)) {
52
443
  return {
53
444
  success: false,
54
445
  error: "This command is blocked for safety reasons.",
55
- stdout: "",
56
- stderr: "",
446
+ output: "",
57
447
  exitCode: 1
58
448
  };
59
449
  }
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);
450
+ const canUseTmux = await shouldUseTmux();
451
+ if (background) {
452
+ if (!canUseTmux) {
453
+ return {
454
+ success: false,
455
+ error: "Background mode requires tmux to be installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
456
+ };
72
457
  }
458
+ const terminalId = generateTerminalId();
459
+ options.onProgress?.({ terminalId, status: "started", command });
460
+ const result = await runBackground(command, options.workingDirectory, {
461
+ sessionId: options.sessionId,
462
+ terminalId
463
+ });
73
464
  return {
74
465
  success: true,
75
- stdout: truncatedStdout,
76
- stderr: truncatedStderr,
77
- exitCode: 0
466
+ id: result.id,
467
+ status: "running",
468
+ message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
78
469
  };
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) {
470
+ }
471
+ if (canUseTmux) {
472
+ const terminalId = generateTerminalId();
473
+ options.onProgress?.({ terminalId, status: "started", command });
474
+ try {
475
+ const result = await runSync(command, options.workingDirectory, {
476
+ sessionId: options.sessionId,
477
+ timeout: COMMAND_TIMEOUT,
478
+ terminalId
479
+ });
480
+ const truncatedOutput = truncateOutput(result.output, MAX_OUTPUT_CHARS2);
481
+ options.onOutput?.(truncatedOutput);
482
+ options.onProgress?.({ terminalId, status: "completed", command });
483
+ return {
484
+ success: result.exitCode === 0,
485
+ id: result.id,
486
+ output: truncatedOutput,
487
+ exitCode: result.exitCode,
488
+ status: result.status
489
+ };
490
+ } catch (error) {
491
+ options.onProgress?.({ terminalId, status: "completed", command });
86
492
  return {
87
493
  success: false,
88
- error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
89
- stdout,
90
- stderr,
91
- exitCode: 124
92
- // Standard timeout exit code
494
+ error: error.message,
495
+ output: "",
496
+ exitCode: 1
93
497
  };
94
498
  }
499
+ } else {
500
+ const result = await execFallback(command, options.workingDirectory, options.onOutput);
95
501
  return {
96
- success: false,
97
- error: error.message,
98
- stdout,
99
- stderr,
100
- exitCode: error.code ?? 1
502
+ success: result.success,
503
+ output: result.output,
504
+ exitCode: result.exitCode,
505
+ error: result.error
101
506
  };
102
507
  }
103
508
  }
@@ -107,9 +512,9 @@ IMPORTANT: Avoid destructive commands. Be careful with rm, chmod, and similar op
107
512
  // src/tools/read-file.ts
108
513
  import { tool as tool2 } from "ai";
109
514
  import { z as z2 } from "zod";
110
- import { readFile, stat } from "fs/promises";
515
+ import { readFile as readFile2, stat } from "fs/promises";
111
516
  import { resolve, relative, isAbsolute } from "path";
112
- import { existsSync } from "fs";
517
+ import { existsSync as existsSync2 } from "fs";
113
518
  var MAX_FILE_SIZE = 5 * 1024 * 1024;
114
519
  var MAX_OUTPUT_CHARS3 = 5e4;
115
520
  var readFileInputSchema = z2.object({
@@ -134,7 +539,7 @@ Use this to understand existing code, check file contents, or gather context.`,
134
539
  content: null
135
540
  };
136
541
  }
137
- if (!existsSync(absolutePath)) {
542
+ if (!existsSync2(absolutePath)) {
138
543
  return {
139
544
  success: false,
140
545
  error: `File not found: ${path}`,
@@ -156,7 +561,7 @@ Use this to understand existing code, check file contents, or gather context.`,
156
561
  content: null
157
562
  };
158
563
  }
159
- let content = await readFile(absolutePath, "utf-8");
564
+ let content = await readFile2(absolutePath, "utf-8");
160
565
  if (startLine !== void 0 || endLine !== void 0) {
161
566
  const lines = content.split("\n");
162
567
  const start = (startLine ?? 1) - 1;
@@ -204,9 +609,9 @@ Use this to understand existing code, check file contents, or gather context.`,
204
609
  // src/tools/write-file.ts
205
610
  import { tool as tool3 } from "ai";
206
611
  import { z as z3 } from "zod";
207
- import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
612
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
208
613
  import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, dirname } from "path";
209
- import { existsSync as existsSync2 } from "fs";
614
+ import { existsSync as existsSync3 } from "fs";
210
615
  var writeFileInputSchema = z3.object({
211
616
  path: z3.string().describe("The path to the file. Can be relative to working directory or absolute."),
212
617
  mode: z3.enum(["full", "str_replace"]).describe('Write mode: "full" for complete file write, "str_replace" for targeted string replacement'),
@@ -251,11 +656,11 @@ Working directory: ${options.workingDirectory}`,
251
656
  };
252
657
  }
253
658
  const dir = dirname(absolutePath);
254
- if (!existsSync2(dir)) {
255
- await mkdir(dir, { recursive: true });
659
+ if (!existsSync3(dir)) {
660
+ await mkdir2(dir, { recursive: true });
256
661
  }
257
- const existed = existsSync2(absolutePath);
258
- await writeFile(absolutePath, content, "utf-8");
662
+ const existed = existsSync3(absolutePath);
663
+ await writeFile2(absolutePath, content, "utf-8");
259
664
  return {
260
665
  success: true,
261
666
  path: absolutePath,
@@ -272,13 +677,13 @@ Working directory: ${options.workingDirectory}`,
272
677
  error: 'Both old_string and new_string are required for "str_replace" mode'
273
678
  };
274
679
  }
275
- if (!existsSync2(absolutePath)) {
680
+ if (!existsSync3(absolutePath)) {
276
681
  return {
277
682
  success: false,
278
683
  error: `File not found: ${path}. Use "full" mode to create new files.`
279
684
  };
280
685
  }
281
- const currentContent = await readFile2(absolutePath, "utf-8");
686
+ const currentContent = await readFile3(absolutePath, "utf-8");
282
687
  if (!currentContent.includes(old_string)) {
283
688
  const lines = currentContent.split("\n");
284
689
  const preview = lines.slice(0, 20).join("\n");
@@ -299,7 +704,7 @@ Working directory: ${options.workingDirectory}`,
299
704
  };
300
705
  }
301
706
  const newContent = currentContent.replace(old_string, new_string);
302
- await writeFile(absolutePath, newContent, "utf-8");
707
+ await writeFile2(absolutePath, newContent, "utf-8");
303
708
  const oldLines = old_string.split("\n").length;
304
709
  const newLines = new_string.split("\n").length;
305
710
  return {
@@ -334,7 +739,7 @@ import { z as z4 } from "zod";
334
739
  import Database from "better-sqlite3";
335
740
  import { drizzle } from "drizzle-orm/better-sqlite3";
336
741
  import { eq, desc, and, sql } from "drizzle-orm";
337
- import { nanoid } from "nanoid";
742
+ import { nanoid as nanoid2 } from "nanoid";
338
743
 
339
744
  // src/db/schema.ts
340
745
  import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
@@ -405,6 +810,15 @@ var terminals = sqliteTable("terminals", {
405
810
  createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
406
811
  stoppedAt: integer("stopped_at", { mode: "timestamp" })
407
812
  });
813
+ var activeStreams = sqliteTable("active_streams", {
814
+ id: text("id").primaryKey(),
815
+ sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
816
+ streamId: text("stream_id").notNull().unique(),
817
+ // Unique stream identifier
818
+ status: text("status", { enum: ["active", "finished", "error"] }).notNull().default("active"),
819
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
820
+ finishedAt: integer("finished_at", { mode: "timestamp" })
821
+ });
408
822
 
409
823
  // src/db/index.ts
410
824
  var db = null;
@@ -416,7 +830,7 @@ function getDb() {
416
830
  }
417
831
  var todoQueries = {
418
832
  create(data) {
419
- const id = nanoid();
833
+ const id = nanoid2();
420
834
  const now = /* @__PURE__ */ new Date();
421
835
  const result = getDb().insert(todoItems).values({
422
836
  id,
@@ -429,7 +843,7 @@ var todoQueries = {
429
843
  createMany(sessionId, items) {
430
844
  const now = /* @__PURE__ */ new Date();
431
845
  const values = items.map((item, index) => ({
432
- id: nanoid(),
846
+ id: nanoid2(),
433
847
  sessionId,
434
848
  content: item.content,
435
849
  order: item.order ?? index,
@@ -455,7 +869,7 @@ var todoQueries = {
455
869
  };
456
870
  var skillQueries = {
457
871
  load(sessionId, skillName) {
458
- const id = nanoid();
872
+ const id = nanoid2();
459
873
  const result = getDb().insert(loadedSkills).values({
460
874
  id,
461
875
  sessionId,
@@ -477,50 +891,6 @@ var skillQueries = {
477
891
  return !!result;
478
892
  }
479
893
  };
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
894
 
525
895
  // src/tools/todo.ts
526
896
  var todoInputSchema = z4.object({
@@ -650,9 +1020,9 @@ import { tool as tool5 } from "ai";
650
1020
  import { z as z6 } from "zod";
651
1021
 
652
1022
  // src/skills/index.ts
653
- import { readFile as readFile3, readdir } from "fs/promises";
1023
+ import { readFile as readFile4, readdir } from "fs/promises";
654
1024
  import { resolve as resolve3, basename, extname } from "path";
655
- import { existsSync as existsSync3 } from "fs";
1025
+ import { existsSync as existsSync4 } from "fs";
656
1026
 
657
1027
  // src/config/types.ts
658
1028
  import { z as z5 } from "zod";
@@ -738,7 +1108,7 @@ function getSkillNameFromPath(filePath) {
738
1108
  return basename(filePath, extname(filePath)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
739
1109
  }
740
1110
  async function loadSkillsFromDirectory(directory) {
741
- if (!existsSync3(directory)) {
1111
+ if (!existsSync4(directory)) {
742
1112
  return [];
743
1113
  }
744
1114
  const skills = [];
@@ -746,7 +1116,7 @@ async function loadSkillsFromDirectory(directory) {
746
1116
  for (const file of files) {
747
1117
  if (!file.endsWith(".md")) continue;
748
1118
  const filePath = resolve3(directory, file);
749
- const content = await readFile3(filePath, "utf-8");
1119
+ const content = await readFile4(filePath, "utf-8");
750
1120
  const parsed = parseSkillFrontmatter(content);
751
1121
  if (parsed) {
752
1122
  skills.push({
@@ -788,7 +1158,7 @@ async function loadSkillContent(skillName, directories) {
788
1158
  if (!skill) {
789
1159
  return null;
790
1160
  }
791
- const content = await readFile3(skill.filePath, "utf-8");
1161
+ const content = await readFile4(skill.filePath, "utf-8");
792
1162
  const parsed = parseSkillFrontmatter(content);
793
1163
  return {
794
1164
  ...skill,
@@ -886,454 +1256,14 @@ Once loaded, a skill's content will be available in the conversation context.`,
886
1256
  });
887
1257
  }
888
1258
 
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
1259
  // src/tools/index.ts
1332
1260
  function createTools(options) {
1333
1261
  return {
1334
1262
  bash: createBashTool({
1335
1263
  workingDirectory: options.workingDirectory,
1336
- onOutput: options.onBashOutput
1264
+ sessionId: options.sessionId,
1265
+ onOutput: options.onBashOutput,
1266
+ onProgress: options.onBashProgress
1337
1267
  }),
1338
1268
  read_file: createReadFileTool({
1339
1269
  workingDirectory: options.workingDirectory
@@ -1347,10 +1277,6 @@ function createTools(options) {
1347
1277
  load_skill: createLoadSkillTool({
1348
1278
  sessionId: options.sessionId,
1349
1279
  skillsDirectories: options.skillsDirectories
1350
- }),
1351
- terminal: createTerminalTool({
1352
- sessionId: options.sessionId,
1353
- workingDirectory: options.workingDirectory
1354
1280
  })
1355
1281
  };
1356
1282
  }
@@ -1358,7 +1284,6 @@ export {
1358
1284
  createBashTool,
1359
1285
  createLoadSkillTool,
1360
1286
  createReadFileTool,
1361
- createTerminalTool,
1362
1287
  createTodoTool,
1363
1288
  createTools,
1364
1289
  createWriteFileTool