smolcoder-plus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/agent.js +748 -0
- package/dist/attachments.js +158 -0
- package/dist/config.js +87 -0
- package/dist/context.js +498 -0
- package/dist/detect.js +474 -0
- package/dist/events.js +24 -0
- package/dist/history.js +9 -0
- package/dist/hosts.js +107 -0
- package/dist/index.js +391 -0
- package/dist/logo.js +48 -0
- package/dist/netscan.js +159 -0
- package/dist/network.js +193 -0
- package/dist/plan.js +102 -0
- package/dist/prompt.js +84 -0
- package/dist/providers/lmstudio.js +347 -0
- package/dist/providers/ollama.js +269 -0
- package/dist/providers/scheduler.js +57 -0
- package/dist/providers/transport.js +86 -0
- package/dist/providers/types.js +62 -0
- package/dist/sandbox.js +207 -0
- package/dist/session.js +639 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +431 -0
- package/dist/tools/index.js +260 -0
- package/dist/tools/search-worker.js +34 -0
- package/dist/tools/shell.js +186 -0
- package/dist/tools/tasks.js +147 -0
- package/dist/tools/web-search.js +155 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +723 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +91 -0
- package/dist/verification.js +71 -0
- package/dist/web/channel.js +260 -0
- package/dist/web/client.js +1010 -0
- package/dist/web/hub.js +952 -0
- package/dist/web/page.js +87 -0
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +333 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +49 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Tool registry. Eight tools with flat parameters — no
|
|
3
|
+
// nested objects or arrays: small models mangle them), an example call inside
|
|
4
|
+
// every description (small models imitate better than they infer), and the
|
|
5
|
+
// mode decides which schemas are sent. The agent rechecks mode at execution.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.MODE_LABELS = void 0;
|
|
8
|
+
exports.buildToolSpecs = buildToolSpecs;
|
|
9
|
+
exports.executeTool = executeTool;
|
|
10
|
+
exports.commandOf = commandOf;
|
|
11
|
+
const fs_tools_1 = require("./fs-tools");
|
|
12
|
+
const check_1 = require("./check");
|
|
13
|
+
const shell_1 = require("./shell");
|
|
14
|
+
const sandbox_1 = require("../sandbox");
|
|
15
|
+
const util_1 = require("../util");
|
|
16
|
+
const search_worker_1 = require("./search-worker");
|
|
17
|
+
const web_search_1 = require("./web-search");
|
|
18
|
+
exports.MODE_LABELS = {
|
|
19
|
+
ro: "read-only",
|
|
20
|
+
edit: "edit",
|
|
21
|
+
bypass: "bypass permissions",
|
|
22
|
+
};
|
|
23
|
+
const TOOL_RESULT_CAP = 10000; // chars — final safety net over per-tool caps
|
|
24
|
+
function buildToolSpecs(mode) {
|
|
25
|
+
const read = [
|
|
26
|
+
{
|
|
27
|
+
name: "read_file",
|
|
28
|
+
description: 'Read a text file in the workspace. Example: {"path": "src/app.js"}. Long files are returned in chunks; pass "offset" (a line number) to continue reading.',
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
path: { type: "string", description: "File path relative to the workspace" },
|
|
33
|
+
offset: { type: "number", description: "Line number to start from (optional)" },
|
|
34
|
+
limit: { type: "number", description: "Max lines to return (optional)" },
|
|
35
|
+
},
|
|
36
|
+
required: ["path"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "list_files",
|
|
41
|
+
description: 'List files and folders in the workspace. Example: {} for everything, or {"path": "src"} for one folder. Folders end with "/".',
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
path: { type: "string", description: "Folder to list (optional, default: whole workspace)" },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "search",
|
|
51
|
+
description: 'Search inside files for a pattern (regular expression; plain text also works). Example: {"pattern": "TODO"}. Returns file:line: matching text.',
|
|
52
|
+
parameters: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
pattern: { type: "string", description: "Text or regex to find" },
|
|
56
|
+
path: { type: "string", description: "Folder to search in (optional)" },
|
|
57
|
+
},
|
|
58
|
+
required: ["pattern"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "web_search",
|
|
63
|
+
description: 'Search the internet using Brave Search API (no local files). Example: {"query": "2026 Asian Games medal table"}. Max results 1-8 via {"query": "...", "maxResults": 3}. Requires BRAVE_API_KEY in .env. Returns numbered titles, snippets and URLs.',
|
|
64
|
+
parameters: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
query: { type: "string", description: "Search query" },
|
|
68
|
+
maxResults: { type: "number", description: "Max results (1-8, default 5)", default: 5 },
|
|
69
|
+
},
|
|
70
|
+
required: ["query"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "plan",
|
|
75
|
+
description: 'Plan runnable increments, kept across compaction. Create: {"action":"set","steps":"wire entry point; run build; add movement and test"}. Finish current step: {"action":"done"} (or supply step). Save exact APIs, error and next edit before a long investigation: {"action":"checkpoint","text":"..."} (max 1000 chars, replaces current step notes). Append: {"action":"add","text":"..."}. Show: {"action":"show"}.',
|
|
76
|
+
parameters: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
action: { type: "string", enum: ["set", "done", "add", "show", "checkpoint"] },
|
|
80
|
+
steps: { type: "string", description: 'The steps, one per line; semicolon lists also accepted (only for "set")' },
|
|
81
|
+
step: { type: "number", description: 'Step number to mark done (optional, for "done")' },
|
|
82
|
+
text: { type: "string", description: 'Step to append or working checkpoint' },
|
|
83
|
+
},
|
|
84
|
+
required: ["action"],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const write = [
|
|
89
|
+
{
|
|
90
|
+
name: "write_file",
|
|
91
|
+
description: 'Create a new file or completely overwrite an existing one. Example: {"path": "src/new.js", "content": "..."}. Parent folders are created automatically. To change part of an existing file, prefer edit_file.',
|
|
92
|
+
parameters: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
path: { type: "string", description: "File path relative to the workspace" },
|
|
96
|
+
content: { type: "string", description: "The full file content" },
|
|
97
|
+
},
|
|
98
|
+
required: ["path", "content"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "edit_file",
|
|
103
|
+
description: 'Replace text inside an existing file. Copy old_text EXACTLY from the file (a few lines, enough to be unique), and give the replacement as new_text. Example: {"path": "src/app.js", "old_text": "const x = 1;", "new_text": "const x = 2;"}',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
path: { type: "string", description: "File path relative to the workspace" },
|
|
108
|
+
old_text: { type: "string", description: "Exact text currently in the file" },
|
|
109
|
+
new_text: { type: "string", description: "Text to replace it with" },
|
|
110
|
+
},
|
|
111
|
+
required: ["path", "old_text", "new_text"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
const exec = [
|
|
116
|
+
{
|
|
117
|
+
name: "run_command",
|
|
118
|
+
description: 'Run a shell command in the workspace and wait for it to finish. Example: {"command": "npm test"}. Times out after 120s — for servers or watchers use the task tool instead.',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
command: { type: "string", description: "The command to run" },
|
|
123
|
+
},
|
|
124
|
+
required: ["command"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "task",
|
|
129
|
+
description: 'Manage background tasks (things that keep running, like dev servers). action "start" runs a command in the background: {"action": "start", "command": "npm run dev"}. action "logs" shows recent output: {"action": "logs", "task_id": "t1"}. action "list" shows all tasks. action "stop" kills one: {"action": "stop", "task_id": "t1"}.',
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
action: { type: "string", enum: ["start", "list", "logs", "stop"] },
|
|
134
|
+
command: { type: "string", description: 'Command to run (only for "start")' },
|
|
135
|
+
task_id: { type: "string", description: 'Task id like "t1" (for "logs" and "stop")' },
|
|
136
|
+
},
|
|
137
|
+
required: ["action"],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
if (mode === "ro")
|
|
142
|
+
return read;
|
|
143
|
+
return [...read, ...write, ...exec];
|
|
144
|
+
}
|
|
145
|
+
async function executeTool(name, args, ctx, signal) {
|
|
146
|
+
try {
|
|
147
|
+
let result;
|
|
148
|
+
switch (name) {
|
|
149
|
+
case "read_file":
|
|
150
|
+
result = (0, fs_tools_1.readFile)(ctx.workspace, args, ctx.resultCharLimit ? ctx.resultCharLimit - 256 : undefined);
|
|
151
|
+
break;
|
|
152
|
+
case "list_files":
|
|
153
|
+
result = (0, fs_tools_1.listFiles)(ctx.workspace, args);
|
|
154
|
+
break;
|
|
155
|
+
case "search":
|
|
156
|
+
result = await (0, search_worker_1.searchFilesBounded)(ctx.workspace, args, signal);
|
|
157
|
+
break;
|
|
158
|
+
case "web_search": {
|
|
159
|
+
if (!args.query || typeof args.query !== "string")
|
|
160
|
+
return 'Error: query is required. Example: {"query": "..."}';
|
|
161
|
+
const maxResults = Number(args.maxResults) || web_search_1.DEFAULT_MAX_RESULTS;
|
|
162
|
+
result = await (0, web_search_1.webSearch)(args.query, maxResults);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "plan": {
|
|
166
|
+
const action = args.action ?? (typeof args.steps === "string" ? "set" : undefined);
|
|
167
|
+
if (action === "set")
|
|
168
|
+
result = ctx.plan.set(typeof args.steps === "string" ? args.steps : "");
|
|
169
|
+
else if (action === "done")
|
|
170
|
+
result = ctx.plan.markDone(args.step === undefined ? undefined : Number(args.step));
|
|
171
|
+
else if (action === "add")
|
|
172
|
+
result = ctx.plan.add(String(args.text ?? ""));
|
|
173
|
+
else if (action === "checkpoint")
|
|
174
|
+
result = ctx.plan.checkpoint(String(args.text ?? ""));
|
|
175
|
+
else if (action === "show")
|
|
176
|
+
result = ctx.plan.modelView();
|
|
177
|
+
else
|
|
178
|
+
return 'Error: action must be one of "set", "done", "add", "show", "checkpoint". Example: {"action": "done"}';
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "write_file":
|
|
182
|
+
result = (0, fs_tools_1.writeFile)(ctx.workspace, args);
|
|
183
|
+
if (!result.startsWith("Error")) {
|
|
184
|
+
ctx.filesTouched.add(String(args.path));
|
|
185
|
+
result += afterWrite(ctx.workspace, String(args.path));
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
case "edit_file":
|
|
189
|
+
result = (0, fs_tools_1.editFile)(ctx.workspace, args);
|
|
190
|
+
if (!result.startsWith("Error")) {
|
|
191
|
+
ctx.filesTouched.add(String(args.path));
|
|
192
|
+
result += afterWrite(ctx.workspace, String(args.path));
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
case "run_command":
|
|
196
|
+
if (typeof args.command !== "string" || !args.command.trim()) {
|
|
197
|
+
return 'Error: command is required. Example: {"command": "npm test"}';
|
|
198
|
+
}
|
|
199
|
+
result = await (0, shell_1.runCommand)(args.command, ctx.workspace, signal);
|
|
200
|
+
ctx.commandsRun.push(`${args.command} → ${result.split("\n").at(-1)}`);
|
|
201
|
+
if (ctx.commandsRun.length > 50)
|
|
202
|
+
ctx.commandsRun.splice(0, ctx.commandsRun.length - 50);
|
|
203
|
+
break;
|
|
204
|
+
case "task": {
|
|
205
|
+
const action = args.action;
|
|
206
|
+
if (action === "start") {
|
|
207
|
+
if (typeof args.command !== "string" || !args.command.trim()) {
|
|
208
|
+
return 'Error: "start" needs a command. Example: {"action": "start", "command": "npm run dev"}';
|
|
209
|
+
}
|
|
210
|
+
ctx.commandsRun.push(`[bg] ${args.command}`);
|
|
211
|
+
result = await ctx.taskManager.startWithEarlyOutput(args.command);
|
|
212
|
+
}
|
|
213
|
+
else if (action === "logs") {
|
|
214
|
+
result = ctx.taskManager.logs(String(args.task_id ?? ""), Number(args.lines) || 50);
|
|
215
|
+
}
|
|
216
|
+
else if (action === "stop") {
|
|
217
|
+
result = ctx.taskManager.stop(String(args.task_id ?? ""));
|
|
218
|
+
}
|
|
219
|
+
else if (action === "list") {
|
|
220
|
+
result = ctx.taskManager.list();
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
return 'Error: action must be one of "start", "list", "logs", "stop". Example: {"action": "list"}';
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
default:
|
|
228
|
+
return `Error: unknown tool "${name}". Available tools are listed in your tool definitions — use one of those.`;
|
|
229
|
+
}
|
|
230
|
+
return (0, util_1.truncateMiddle)(result, TOOL_RESULT_CAP);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (err instanceof sandbox_1.SandboxError)
|
|
234
|
+
return `Error: ${err.message}`;
|
|
235
|
+
return `Error: ${err?.message ?? String(err)}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Post-write hook: parse what was just written and coach on the first
|
|
239
|
+
* syntax error. A one-line warning riding on the success message is the
|
|
240
|
+
* cheapest possible feedback loop for a local model. */
|
|
241
|
+
function afterWrite(workspace, relPath) {
|
|
242
|
+
try {
|
|
243
|
+
const abs = (0, sandbox_1.resolveInWorkspace)(workspace, relPath);
|
|
244
|
+
const warning = (0, check_1.syntaxCheck)(abs, relPath);
|
|
245
|
+
return warning ? `
|
|
246
|
+
Warning: ${warning} Fix this before moving on (use edit_file).` : "";
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return "";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The command a call would run, if it is an exec call (edit mode may gate it;
|
|
253
|
+
* bypass never asks; in ro mode the tool does not exist). */
|
|
254
|
+
function commandOf(name, args) {
|
|
255
|
+
if (name === "run_command")
|
|
256
|
+
return String(args.command ?? "");
|
|
257
|
+
if (name === "task" && args.action === "start")
|
|
258
|
+
return String(args.command ?? "");
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.searchFilesBounded = searchFilesBounded;
|
|
4
|
+
const worker_threads_1 = require("worker_threads");
|
|
5
|
+
const fs_tools_1 = require("./fs-tools");
|
|
6
|
+
if (!worker_threads_1.isMainThread)
|
|
7
|
+
worker_threads_1.parentPort.postMessage((0, fs_tools_1.searchFiles)(worker_threads_1.workerData.root, worker_threads_1.workerData.args));
|
|
8
|
+
/** A pathological regex can be terminated without freezing the agent/UI. */
|
|
9
|
+
function searchFilesBounded(root, args, signal, timeoutMs = 5000) {
|
|
10
|
+
if (signal?.aborted)
|
|
11
|
+
return Promise.resolve("Error: search cancelled");
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const worker = new worker_threads_1.Worker(__filename, { workerData: { root, args } });
|
|
14
|
+
let settled = false;
|
|
15
|
+
const finish = (result) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
signal?.removeEventListener("abort", cancel);
|
|
21
|
+
void worker.terminate();
|
|
22
|
+
resolve(result);
|
|
23
|
+
};
|
|
24
|
+
const cancel = () => finish("Error: search cancelled");
|
|
25
|
+
const timer = setTimeout(() => finish("Error: search timed out. Use a simpler pattern or search a smaller folder."), timeoutMs);
|
|
26
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
27
|
+
if (signal?.aborted)
|
|
28
|
+
cancel();
|
|
29
|
+
worker.once("message", finish);
|
|
30
|
+
worker.once("error", (err) => finish(`Error: search failed: ${err.message}`));
|
|
31
|
+
worker.once("exit", (code) => { if (!settled)
|
|
32
|
+
finish(`Error: search worker exited (${code})`); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// run_command: one-shot foreground commands, cwd-locked to the workspace.
|
|
3
|
+
// Shell picking matters on Windows: local models emit POSIX commands, so we
|
|
4
|
+
// prefer Git Bash when it exists, skip WSL's System32 bash (different
|
|
5
|
+
// filesystem world), and fall back to PowerShell. The chosen shell is named in
|
|
6
|
+
// the system prompt so the model knows what dialect to write.
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.pickShell = pickShell;
|
|
42
|
+
exports.killTree = killTree;
|
|
43
|
+
exports.managedCommand = managedCommand;
|
|
44
|
+
exports.runCommand = runCommand;
|
|
45
|
+
const child_process_1 = require("child_process");
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const util_1 = require("../util");
|
|
49
|
+
let cached = null;
|
|
50
|
+
function pickShell() {
|
|
51
|
+
if (cached)
|
|
52
|
+
return cached;
|
|
53
|
+
if (process.platform === "win32") {
|
|
54
|
+
const candidates = [
|
|
55
|
+
path.join(process.env["ProgramFiles"] ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
56
|
+
path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
57
|
+
path.join(process.env["LOCALAPPDATA"] ?? "", "Programs", "Git", "bin", "bash.exe"),
|
|
58
|
+
];
|
|
59
|
+
for (const p of candidates) {
|
|
60
|
+
if (p && fs.existsSync(p)) {
|
|
61
|
+
cached = { exe: p, argsFor: (cmd) => ["-o", "pipefail", "-lc", cmd], label: "bash (Git Bash)" };
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// any bash on PATH that is not WSL's System32 shim
|
|
66
|
+
const where = (0, child_process_1.spawnSync)("where.exe", ["bash"], { encoding: "utf8" });
|
|
67
|
+
if (where.status === 0) {
|
|
68
|
+
const found = where.stdout
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.map((s) => s.trim())
|
|
71
|
+
.find((p) => p && !p.toLowerCase().includes("system32"));
|
|
72
|
+
if (found) {
|
|
73
|
+
cached = { exe: found, argsFor: (cmd) => ["-o", "pipefail", "-lc", cmd], label: "bash (Git Bash)" };
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
cached = {
|
|
78
|
+
exe: "powershell.exe",
|
|
79
|
+
argsFor: (cmd) => ["-NoProfile", "-NonInteractive", "-Command", cmd],
|
|
80
|
+
label: "PowerShell",
|
|
81
|
+
};
|
|
82
|
+
return cached;
|
|
83
|
+
}
|
|
84
|
+
const sh = fs.existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh";
|
|
85
|
+
cached = { exe: sh, argsFor: (cmd) => sh.endsWith("/bash") ? ["-o", "pipefail", "-lc", cmd] : ["-lc", cmd], label: path.basename(sh) };
|
|
86
|
+
return cached;
|
|
87
|
+
}
|
|
88
|
+
function killTree(pid) {
|
|
89
|
+
try {
|
|
90
|
+
if (process.platform === "win32") {
|
|
91
|
+
(0, child_process_1.spawnSync)("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore" });
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
process.kill(-pid, "SIGKILL");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(pid, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* already gone */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const OUTPUT_CAP = 8000;
|
|
107
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
108
|
+
/** Keep bash alive while ordinary background jobs own its output pipes. On
|
|
109
|
+
* Windows taskkill cannot find descendants after their shell has exited. */
|
|
110
|
+
function managedCommand(shell, command) {
|
|
111
|
+
return /(?:^|[\\/])bash(?:\.exe)?$/.test(shell.exe)
|
|
112
|
+
? `${command}\n__smol_command_status=$?\nwait\nexit "$__smol_command_status"`
|
|
113
|
+
: command;
|
|
114
|
+
}
|
|
115
|
+
function runCommand(command, cwd, signal) {
|
|
116
|
+
if (signal?.aborted)
|
|
117
|
+
return Promise.resolve("Error: command cancelled before starting");
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const shell = pickShell();
|
|
120
|
+
let output = "";
|
|
121
|
+
let finished = false;
|
|
122
|
+
const started = Date.now();
|
|
123
|
+
const proc = (0, child_process_1.spawn)(shell.exe, shell.argsFor(managedCommand(shell, command)), {
|
|
124
|
+
cwd,
|
|
125
|
+
env: process.env,
|
|
126
|
+
detached: process.platform !== "win32", // process group for killTree
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
129
|
+
});
|
|
130
|
+
const append = (chunk) => {
|
|
131
|
+
// Keep the end of a long build/test log: failures usually appear there.
|
|
132
|
+
// Dropping all output after 32k hid the actual failure from the model.
|
|
133
|
+
output += chunk.toString("utf8");
|
|
134
|
+
if (output.length > OUTPUT_CAP * 4)
|
|
135
|
+
output = (0, util_1.truncateMiddle)(output, OUTPUT_CAP * 4);
|
|
136
|
+
};
|
|
137
|
+
proc.stdout.on("data", append);
|
|
138
|
+
proc.stderr.on("data", append);
|
|
139
|
+
// User interrupt (esc / ctrl+c / web stop button): kill the whole tree now.
|
|
140
|
+
const onAbort = () => {
|
|
141
|
+
if (finished)
|
|
142
|
+
return;
|
|
143
|
+
finished = true;
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
cleanup();
|
|
146
|
+
killTree(proc.pid);
|
|
147
|
+
proc.stdout.destroy();
|
|
148
|
+
proc.stderr.destroy();
|
|
149
|
+
resolve((output.trim() ? (0, util_1.truncateMiddle)(output, OUTPUT_CAP) + "\n" : "") +
|
|
150
|
+
"[command cancelled by the user before it finished]");
|
|
151
|
+
};
|
|
152
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
153
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
if (finished)
|
|
156
|
+
return;
|
|
157
|
+
finished = true;
|
|
158
|
+
cleanup();
|
|
159
|
+
killTree(proc.pid);
|
|
160
|
+
proc.stdout.destroy();
|
|
161
|
+
proc.stderr.destroy();
|
|
162
|
+
resolve("Error: " + (0, util_1.truncateMiddle)(output, OUTPUT_CAP) +
|
|
163
|
+
`\n[command timed out after ${DEFAULT_TIMEOUT_MS / 1000}s and was killed. For tests/builds, isolate the stuck test or phase and inspect its loop or initialization before rerunning. For a persistent dev server, use task {"action": "start"}.]`);
|
|
164
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
165
|
+
proc.on("error", (err) => {
|
|
166
|
+
if (finished)
|
|
167
|
+
return;
|
|
168
|
+
finished = true;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
cleanup();
|
|
171
|
+
resolve(`Error: could not start command: ${err.message}`);
|
|
172
|
+
});
|
|
173
|
+
proc.on("close", (code) => {
|
|
174
|
+
if (finished)
|
|
175
|
+
return;
|
|
176
|
+
finished = true;
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
cleanup();
|
|
179
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
180
|
+
const body = output.trim() ? (0, util_1.truncateMiddle)(output, OUTPUT_CAP) : "(no output)";
|
|
181
|
+
resolve(`${code !== 0 ? `Error: command exited with code ${code ?? "?"}\n` : ""}${body}\n[exit code ${code ?? "?"} in ${secs}s]`);
|
|
182
|
+
});
|
|
183
|
+
if (signal?.aborted)
|
|
184
|
+
onAbort();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Background tasks: one `task` tool with an action enum instead of four
|
|
3
|
+
// separate tools — one schema costs fewer context tokens, and small models
|
|
4
|
+
// handle enum dispatch on a single tool fine. Each task keeps a ring buffer of
|
|
5
|
+
// recent output so the agent (and the user, via /tasks and /logs) has
|
|
6
|
+
// visibility. All tasks are killed when smolcoder exits.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.TaskManager = void 0;
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const shell_1 = require("./shell");
|
|
11
|
+
const RING_SIZE = 300;
|
|
12
|
+
class TaskManager {
|
|
13
|
+
cwd;
|
|
14
|
+
tasks = new Map();
|
|
15
|
+
counter = 0;
|
|
16
|
+
constructor(cwd) {
|
|
17
|
+
this.cwd = cwd;
|
|
18
|
+
}
|
|
19
|
+
start(command) {
|
|
20
|
+
const shell = (0, shell_1.pickShell)();
|
|
21
|
+
const id = `t${++this.counter}`;
|
|
22
|
+
const proc = (0, child_process_1.spawn)(shell.exe, shell.argsFor((0, shell_1.managedCommand)(shell, command)), {
|
|
23
|
+
cwd: this.cwd,
|
|
24
|
+
env: process.env,
|
|
25
|
+
detached: process.platform !== "win32",
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
const task = {
|
|
30
|
+
id,
|
|
31
|
+
command,
|
|
32
|
+
proc,
|
|
33
|
+
lines: [],
|
|
34
|
+
status: "running",
|
|
35
|
+
exitCode: null,
|
|
36
|
+
startedAt: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
const push = (chunk) => {
|
|
39
|
+
for (const line of chunk.toString("utf8").split(/\r?\n/)) {
|
|
40
|
+
if (line === "")
|
|
41
|
+
continue;
|
|
42
|
+
task.lines.push(line);
|
|
43
|
+
if (task.lines.length > RING_SIZE)
|
|
44
|
+
task.lines.shift();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
proc.stdout?.on("data", push);
|
|
48
|
+
proc.stderr?.on("data", push);
|
|
49
|
+
proc.on("error", (err) => {
|
|
50
|
+
task.lines.push(`[failed to start: ${err.message}]`);
|
|
51
|
+
task.status = "exited";
|
|
52
|
+
task.exitCode = -1;
|
|
53
|
+
});
|
|
54
|
+
proc.on("close", (code) => {
|
|
55
|
+
if (task.status === "running") {
|
|
56
|
+
task.status = "exited";
|
|
57
|
+
task.exitCode = code;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
this.tasks.set(id, task);
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
/** Wait briefly after start so early output (or an instant crash) is visible. */
|
|
64
|
+
async startWithEarlyOutput(command) {
|
|
65
|
+
const id = this.start(command);
|
|
66
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
67
|
+
const task = this.tasks.get(id);
|
|
68
|
+
const early = task.lines.slice(-15).join("\n");
|
|
69
|
+
const status = task.status === "running"
|
|
70
|
+
? `Task ${id} is running in the background.`
|
|
71
|
+
: `Task ${id} exited almost immediately (exit code ${task.exitCode}).`;
|
|
72
|
+
return `${status} Command: ${command}\n${early ? `Early output:\n${early}\n` : ""}Use task {"action": "logs", "task_id": "${id}"} to check on it, {"action": "stop"} to kill it.`;
|
|
73
|
+
}
|
|
74
|
+
logs(taskId, lineCount = 50) {
|
|
75
|
+
const task = this.tasks.get(taskId);
|
|
76
|
+
if (!task)
|
|
77
|
+
return this.unknownTask(taskId);
|
|
78
|
+
const tail = task.lines.slice(-Math.min(Math.max(lineCount, 1), RING_SIZE));
|
|
79
|
+
const header = `Task ${task.id} [${task.status}${task.exitCode !== null ? ` code ${task.exitCode}` : ""}] ${task.command}`;
|
|
80
|
+
return `${header}\n${tail.length ? tail.join("\n") : "(no output yet)"}`;
|
|
81
|
+
}
|
|
82
|
+
stop(taskId) {
|
|
83
|
+
const task = this.tasks.get(taskId);
|
|
84
|
+
if (!task)
|
|
85
|
+
return this.unknownTask(taskId);
|
|
86
|
+
if (task.status !== "running")
|
|
87
|
+
return `Task ${taskId} already ${task.status}.`;
|
|
88
|
+
task.status = "stopped";
|
|
89
|
+
(0, shell_1.killTree)(task.proc.pid);
|
|
90
|
+
task.proc.stdout?.destroy();
|
|
91
|
+
task.proc.stderr?.destroy();
|
|
92
|
+
return `Task ${taskId} stopped. (${task.command})`;
|
|
93
|
+
}
|
|
94
|
+
list() {
|
|
95
|
+
if (this.tasks.size === 0)
|
|
96
|
+
return "No background tasks. Start one with task {\"action\": \"start\", \"command\": \"...\"}.";
|
|
97
|
+
const rows = [...this.tasks.values()].map((t) => {
|
|
98
|
+
const age = Math.round((Date.now() - t.startedAt) / 1000);
|
|
99
|
+
const status = t.status === "running" ? "running" : `${t.status}${t.exitCode !== null ? `(${t.exitCode})` : ""}`;
|
|
100
|
+
return `${t.id} ${status} ${age}s ${t.command}`;
|
|
101
|
+
});
|
|
102
|
+
return "id status age command\n" + rows.join("\n");
|
|
103
|
+
}
|
|
104
|
+
hasRunning() {
|
|
105
|
+
return [...this.tasks.values()].some((t) => t.status === "running");
|
|
106
|
+
}
|
|
107
|
+
runningSummary() {
|
|
108
|
+
return [...this.tasks.values()]
|
|
109
|
+
.filter((t) => t.status === "running")
|
|
110
|
+
.map((t) => `${t.id}: ${t.command}`);
|
|
111
|
+
}
|
|
112
|
+
/** http://localhost-style URLs printed by running tasks — what a dev server
|
|
113
|
+
* announces on start — so the web UI can offer them in its browser panel. */
|
|
114
|
+
recentUrls() {
|
|
115
|
+
const out = new Set();
|
|
116
|
+
const re = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::\d+)?(?:\/[^\s"'<>)\]]*)?/g;
|
|
117
|
+
for (const t of this.tasks.values()) {
|
|
118
|
+
if (t.status !== "running")
|
|
119
|
+
continue;
|
|
120
|
+
for (const line of t.lines) {
|
|
121
|
+
for (const m of line.matchAll(re))
|
|
122
|
+
out.add(m[0].replace("0.0.0.0", "localhost").replace(/\/$/, ""));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return [...out].slice(0, 8);
|
|
126
|
+
}
|
|
127
|
+
killAll() {
|
|
128
|
+
for (const t of this.tasks.values()) {
|
|
129
|
+
if (t.status === "running") {
|
|
130
|
+
t.status = "stopped";
|
|
131
|
+
try {
|
|
132
|
+
(0, shell_1.killTree)(t.proc.pid);
|
|
133
|
+
t.proc.stdout?.destroy();
|
|
134
|
+
t.proc.stderr?.destroy();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
unknownTask(taskId) {
|
|
143
|
+
const known = [...this.tasks.keys()].join(", ") || "none";
|
|
144
|
+
return `Error: no task with id "${taskId}". Known tasks: ${known}. Use task {"action": "list"} to see them.`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.TaskManager = TaskManager;
|