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.
- package/README.md +2 -2
- package/dist/agent/index.d.ts +3 -2
- package/dist/agent/index.js +653 -574
- package/dist/agent/index.js.map +1 -1
- package/dist/bash-CGAqW7HR.d.ts +80 -0
- package/dist/cli.js +2912 -1374
- package/dist/cli.js.map +1 -1
- package/dist/db/index.d.ts +16 -3
- package/dist/db/index.js +68 -13
- package/dist/db/index.js.map +1 -1
- package/dist/{index-BxpkHy7X.d.ts → index-DkR9Ln_7.d.ts} +18 -2
- package/dist/index.d.ts +117 -79
- package/dist/index.js +2402 -1242
- package/dist/index.js.map +1 -1
- package/dist/{schema-EPbMMFza.d.ts → schema-cUDLVN-b.d.ts} +127 -5
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +2390 -1245
- package/dist/server/index.js.map +1 -1
- package/dist/tools/index.d.ts +4 -138
- package/dist/tools/index.js +483 -558
- 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,254 @@ 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
|
+
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
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
|
-
|
|
56
|
-
stderr: "",
|
|
446
|
+
output: "",
|
|
57
447
|
exitCode: 1
|
|
58
448
|
};
|
|
59
449
|
}
|
|
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);
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
466
|
+
id: result.id,
|
|
467
|
+
status: "running",
|
|
468
|
+
message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
|
|
78
469
|
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
255
|
-
await
|
|
659
|
+
if (!existsSync3(dir)) {
|
|
660
|
+
await mkdir2(dir, { recursive: true });
|
|
256
661
|
}
|
|
257
|
-
const existed =
|
|
258
|
-
await
|
|
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 (!
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
1023
|
+
import { readFile as readFile4, readdir } from "fs/promises";
|
|
654
1024
|
import { resolve as resolve3, basename, extname } from "path";
|
|
655
|
-
import { existsSync as
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|