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.
- package/README.md +2 -2
- package/dist/agent/index.d.ts +3 -2
- package/dist/agent/index.js +813 -566
- package/dist/agent/index.js.map +1 -1
- package/dist/bash-CGAqW7HR.d.ts +80 -0
- package/dist/cli.js +3044 -1081
- package/dist/cli.js.map +1 -1
- package/dist/db/index.d.ts +67 -3
- package/dist/db/index.js +252 -13
- package/dist/db/index.js.map +1 -1
- package/dist/{index-BxpkHy7X.d.ts → index-Btr542-G.d.ts} +18 -2
- package/dist/index.d.ts +178 -77
- package/dist/index.js +2537 -976
- package/dist/index.js.map +1 -1
- package/dist/{schema-EPbMMFza.d.ts → schema-CkrIadxa.d.ts} +371 -5
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +2483 -945
- package/dist/server/index.js.map +1 -1
- package/dist/tools/index.d.ts +5 -138
- package/dist/tools/index.js +787 -723
- package/dist/tools/index.js.map +1 -1
- package/package.json +4 -2
package/dist/tools/index.js
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
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
|
-
|
|
56
|
-
stderr: "",
|
|
445
|
+
output: "",
|
|
57
446
|
exitCode: 1
|
|
58
447
|
};
|
|
59
448
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
465
|
+
id: result.id,
|
|
466
|
+
status: "running",
|
|
467
|
+
message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
|
|
78
468
|
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
208
|
-
import { resolve as
|
|
209
|
-
import { existsSync as
|
|
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 :
|
|
239
|
-
const relativePath =
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 =
|
|
258
|
-
await
|
|
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:
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
654
|
-
import { resolve as
|
|
655
|
-
import { existsSync as
|
|
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 (!
|
|
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 =
|
|
749
|
-
const content = await
|
|
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
|
|
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
|
-
|
|
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
|