s-app-scoder-agent 0.4.0 → 0.6.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/README.md +127 -28
- package/dist/agent.js +75 -5
- package/dist/build.js +3 -2
- package/dist/index.js +171 -1
- package/dist/llm.js +25 -2
- package/dist/server.js +256 -0
- package/dist/tools.js +62 -11
- package/dist/tui/app.js +315 -0
- package/dist/tui/commands.js +24 -0
- package/dist/tui/state.js +166 -0
- package/dist/tui-direct.mjs +4 -0
- package/dist/ui.js +116 -10
- package/dist/webfetch.js +251 -0
- package/package.json +6 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
|
|
|
8
8
|
import { JsonlLogger } from "./logger.js";
|
|
9
9
|
import { collectUsage, formatCostReport } from "./cost.js";
|
|
10
10
|
import { getVersion as getPackageVersion } from "./version.js";
|
|
11
|
+
import { formatSlashHelp, parseSlashCommand } from "./tui/commands.js";
|
|
11
12
|
import * as ui from "./ui.js";
|
|
12
13
|
// Single source of truth: read version from package.json (see src/version.ts).
|
|
13
14
|
const VERSION = getPackageVersion();
|
|
@@ -16,11 +17,16 @@ const HELP = `scoder — autonomous coding agent CLI
|
|
|
16
17
|
USAGE
|
|
17
18
|
scoder [options] "<task>" Run a task autonomously until the build passes
|
|
18
19
|
scoder Start interactive session (chat + tools)
|
|
20
|
+
scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
|
|
21
|
+
scoder init First-run wizard: writes .scoderrc.json + tests the connection
|
|
19
22
|
scoder list List saved sessions for this directory
|
|
20
23
|
scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
|
|
21
24
|
scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
|
|
22
25
|
scoder cost [--session <id>] [--last <n>]
|
|
23
26
|
Token usage & estimated cost from JSONL logs
|
|
27
|
+
scoder serve [--port <n>] [--host <addr>]
|
|
28
|
+
Run as an HTTP server: POST tasks, stream
|
|
29
|
+
progress via Server-Sent Events (see below)
|
|
24
30
|
scoder --version | --help
|
|
25
31
|
|
|
26
32
|
OPTIONS
|
|
@@ -120,6 +126,17 @@ function parseArgs(argv) {
|
|
|
120
126
|
args.provider = p;
|
|
121
127
|
break;
|
|
122
128
|
}
|
|
129
|
+
case "--port": {
|
|
130
|
+
const n = Number(next());
|
|
131
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
132
|
+
throw new Error(`Invalid --port value (expected 0-65535): ${n}`);
|
|
133
|
+
}
|
|
134
|
+
args.servePort = n;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "--host":
|
|
138
|
+
args.serveHost = next();
|
|
139
|
+
break;
|
|
123
140
|
case "-h":
|
|
124
141
|
case "--help":
|
|
125
142
|
args.help = true;
|
|
@@ -155,6 +172,19 @@ function parseArgs(argv) {
|
|
|
155
172
|
args.subcommand = "cost";
|
|
156
173
|
args.task = null;
|
|
157
174
|
}
|
|
175
|
+
else if (rest[0] === "serve") {
|
|
176
|
+
args.subcommand = "serve";
|
|
177
|
+
args.task = null;
|
|
178
|
+
}
|
|
179
|
+
else if (rest[0] === "tui") {
|
|
180
|
+
// scoder tui [initial-task] — full-screen Ink interface
|
|
181
|
+
args.subcommand = "tui";
|
|
182
|
+
args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
|
|
183
|
+
}
|
|
184
|
+
else if (rest[0] === "init") {
|
|
185
|
+
args.subcommand = "init";
|
|
186
|
+
args.task = null;
|
|
187
|
+
}
|
|
158
188
|
return args;
|
|
159
189
|
}
|
|
160
190
|
async function runOnce(cfg, task) {
|
|
@@ -175,13 +205,79 @@ async function runOnce(cfg, task) {
|
|
|
175
205
|
return 2;
|
|
176
206
|
}
|
|
177
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* `scoder init` — first-run wizard: asks for the essentials, writes
|
|
210
|
+
* .scoderrc.json, then offers a live connection test against the provider.
|
|
211
|
+
*/
|
|
212
|
+
export async function runInit() {
|
|
213
|
+
const { existsSync, writeFileSync } = await import("node:fs");
|
|
214
|
+
const rcPath = resolve(process.cwd(), ".scoderrc.json");
|
|
215
|
+
ui.banner("scoder init — configure this project", [
|
|
216
|
+
["file", rcPath],
|
|
217
|
+
["note", "answers are saved to .scoderrc.json (env vars still override)"],
|
|
218
|
+
]);
|
|
219
|
+
if (existsSync(rcPath)) {
|
|
220
|
+
const rl0 = createInterface({ input: process.stdin, output: process.stdout });
|
|
221
|
+
const overwrite = await new Promise((res) => rl0.question(`.scoderrc.json already exists — overwrite? [y/N] `, res));
|
|
222
|
+
rl0.close();
|
|
223
|
+
if (!/^y(es)?$/i.test(overwrite.trim())) {
|
|
224
|
+
ui.info("kept the existing .scoderrc.json");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
229
|
+
const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
|
|
230
|
+
const baseUrl = await ask("API base URL (OpenAI-compatible)", "https://api.openai.com/v1");
|
|
231
|
+
const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
|
|
232
|
+
const model = await ask("Model", "gpt-4o-mini");
|
|
233
|
+
const buildCmd = await ask("Build command (empty = auto-detect)", "");
|
|
234
|
+
const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
|
|
235
|
+
rl.close();
|
|
236
|
+
const provider = /api\.anthropic\.com/.test(baseUrl) || /^claude-/i.test(model) ? "anthropic" : "openai";
|
|
237
|
+
const rc = {
|
|
238
|
+
model,
|
|
239
|
+
baseUrl,
|
|
240
|
+
provider,
|
|
241
|
+
...(buildCmd ? { buildCmd } : {}),
|
|
242
|
+
permissions: { mode: ["auto", "ask", "readonly"].includes(permission) ? permission : "auto" },
|
|
243
|
+
};
|
|
244
|
+
writeFileSync(rcPath, JSON.stringify(rc, null, 2) + "\n");
|
|
245
|
+
ui.success(`wrote ${rcPath} (provider: ${provider})`);
|
|
246
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
247
|
+
const doTest = await new Promise((res) => rl2.question("Test the connection now? [Y/n] ", res));
|
|
248
|
+
rl2.close();
|
|
249
|
+
if (!/^y(es)?$/i.test(doTest.trim()) && doTest.trim() !== "") {
|
|
250
|
+
ui.info("skipped connection test");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!apiKey) {
|
|
254
|
+
ui.warn("no API key provided — set SCODER_API_KEY/OPENAI_API_KEY before running tasks");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
ui.info(`connecting to ${baseUrl} (${provider}) …`);
|
|
258
|
+
try {
|
|
259
|
+
const { LlmClient } = await import("./llm.js");
|
|
260
|
+
const { AnthropicClient } = await import("./provider-anthropic.js");
|
|
261
|
+
const client = provider === "anthropic"
|
|
262
|
+
? new AnthropicClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 })
|
|
263
|
+
: new LlmClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 });
|
|
264
|
+
const res = await client.chat([{ role: "user", content: "Reply with exactly: OK" }], []);
|
|
265
|
+
const reply = (res.message.content ?? "").trim();
|
|
266
|
+
ui.success(`connection OK — model replied: ${JSON.stringify(reply)}`);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
ui.error(`connection test failed: ${err.message}`);
|
|
270
|
+
process.exitCode = 2;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
178
273
|
async function repl(cfg) {
|
|
179
274
|
ui.header(`scoder interactive — model: ${cfg.model}`);
|
|
180
275
|
ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
|
|
181
276
|
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
|
|
182
277
|
// One shared session for the whole REPL conversation (plan P0.5 §4):
|
|
183
278
|
// every input is a follow-up turn, context carries over, saved continuously.
|
|
184
|
-
|
|
279
|
+
// /clear swaps in a fresh Agent (the /clear branch reassigns this).
|
|
280
|
+
let agent = new Agent(cfg);
|
|
185
281
|
process.on("SIGINT", () => {
|
|
186
282
|
ui.warn("\nSIGINT — aborting current task...");
|
|
187
283
|
agent.abort();
|
|
@@ -194,6 +290,37 @@ async function repl(cfg) {
|
|
|
194
290
|
continue;
|
|
195
291
|
if (input === "exit" || input === "quit")
|
|
196
292
|
break;
|
|
293
|
+
// Slash commands — handled by the UI, never sent to the LLM.
|
|
294
|
+
const slash = parseSlashCommand(input);
|
|
295
|
+
if (slash) {
|
|
296
|
+
if (slash.cmd === "/exit" || slash.cmd === "/quit")
|
|
297
|
+
break;
|
|
298
|
+
if (slash.cmd === "/help") {
|
|
299
|
+
console.log(formatSlashHelp("scoder"));
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (slash.cmd === "/clear") {
|
|
303
|
+
agent.abort();
|
|
304
|
+
agent = new Agent(cfg);
|
|
305
|
+
first = true;
|
|
306
|
+
ui.info("conversation cleared — context reset");
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (slash.cmd === "/cost") {
|
|
310
|
+
const u = agent.getUsage();
|
|
311
|
+
console.log(u.calls > 0
|
|
312
|
+
? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
|
|
313
|
+
(u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
|
|
314
|
+
: "no LLM calls yet in this session");
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (slash.cmd === "/model") {
|
|
318
|
+
console.log(`model: ${cfg.model} · provider: ${cfg.provider} · build: ${cfg.buildCmd ?? "auto-detect"}`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
ui.error(`unknown command ${slash.cmd} — try /help`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
197
324
|
try {
|
|
198
325
|
const result = first ? await agent.run(input) : await agent.runTurn(input);
|
|
199
326
|
first = false;
|
|
@@ -238,6 +365,10 @@ async function main() {
|
|
|
238
365
|
logFile: args.logFile,
|
|
239
366
|
});
|
|
240
367
|
// Subcommands
|
|
368
|
+
if (args.subcommand === "init") {
|
|
369
|
+
await runInit();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
241
372
|
if (args.subcommand === "list") {
|
|
242
373
|
const sessions = listSessions(cfg.workdir);
|
|
243
374
|
if (sessions.length === 0) {
|
|
@@ -302,6 +433,45 @@ async function main() {
|
|
|
302
433
|
console.log(formatCostReport(sessionCosts));
|
|
303
434
|
return;
|
|
304
435
|
}
|
|
436
|
+
if (args.subcommand === "tui") {
|
|
437
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
438
|
+
ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
|
|
439
|
+
await repl(cfg);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!cfg.apiKey) {
|
|
443
|
+
ui.warn("No API key configured — tasks will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
|
|
444
|
+
}
|
|
445
|
+
const { runTui } = await import("./tui/app.js");
|
|
446
|
+
await runTui(cfg, args.task);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (args.subcommand === "serve") {
|
|
450
|
+
const { startServer } = await import("./server.js");
|
|
451
|
+
if (!cfg.apiKey) {
|
|
452
|
+
ui.warn("No API key configured — runs will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
|
|
453
|
+
}
|
|
454
|
+
const srv = await startServer(cfg, { host: args.serveHost, port: args.servePort });
|
|
455
|
+
const token = process.env.SCODER_SERVER_TOKEN ? " · token auth" : "";
|
|
456
|
+
ui.banner(`scoder serve @ http://${args.serveHost ?? "127.0.0.1"}:${srv.port}${token}`, [
|
|
457
|
+
["task", `POST /api/runs { "task": "..." } → 202 { id, eventsUrl }`],
|
|
458
|
+
["events", `GET /api/runs/<id>/events → SSE stream`],
|
|
459
|
+
["list", `GET /api/runs`],
|
|
460
|
+
["abort", `POST /api/runs/<id>/abort`],
|
|
461
|
+
["workdir", cfg.workdir],
|
|
462
|
+
["model", `${cfg.model} · permission ${cfg.permissionMode}`],
|
|
463
|
+
]);
|
|
464
|
+
const shutdown = async () => {
|
|
465
|
+
ui.info("shutting down server...");
|
|
466
|
+
await srv.close();
|
|
467
|
+
process.exit(0);
|
|
468
|
+
};
|
|
469
|
+
process.on("SIGINT", () => {
|
|
470
|
+
ui.warn("\nSIGINT — shutting down server, aborting in-flight runs...");
|
|
471
|
+
void shutdown();
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
305
475
|
if (args.task) {
|
|
306
476
|
const code = await runOnce(cfg, args.task);
|
|
307
477
|
process.exit(code);
|
package/dist/llm.js
CHANGED
|
@@ -76,6 +76,29 @@ function parseSSELine(line) {
|
|
|
76
76
|
// ---------------------------------------------------------------------------
|
|
77
77
|
// LlmClient
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Serialize conversation history to the OpenAI wire format. Internal ToolCall
|
|
81
|
+
* shape is `{id, name, arguments}`; the API expects
|
|
82
|
+
* `{id, type: "function", function: {name, arguments}}`. Some providers
|
|
83
|
+
* (Upstage/solar) reject assistant tool_calls in the internal shape with a
|
|
84
|
+
* misleading "Invalid function name: ''" error — always normalize here.
|
|
85
|
+
*/
|
|
86
|
+
function toWireMessages(messages) {
|
|
87
|
+
return messages.map((m) => {
|
|
88
|
+
if (m.role === "assistant" && m.tool_calls) {
|
|
89
|
+
return {
|
|
90
|
+
role: m.role,
|
|
91
|
+
content: m.content,
|
|
92
|
+
tool_calls: m.tool_calls.map((tc) => ({
|
|
93
|
+
id: tc.id,
|
|
94
|
+
type: "function",
|
|
95
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return m;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
79
102
|
export class LlmClient {
|
|
80
103
|
opts;
|
|
81
104
|
streamEnabled;
|
|
@@ -92,7 +115,7 @@ export class LlmClient {
|
|
|
92
115
|
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
93
116
|
const body = JSON.stringify({
|
|
94
117
|
model: this.opts.model,
|
|
95
|
-
messages,
|
|
118
|
+
messages: toWireMessages(messages),
|
|
96
119
|
tools: tools.length > 0 ? tools : undefined,
|
|
97
120
|
temperature: this.opts.temperature,
|
|
98
121
|
max_tokens: this.opts.maxTokens,
|
|
@@ -153,7 +176,7 @@ export class LlmClient {
|
|
|
153
176
|
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
154
177
|
const bodyStr = JSON.stringify({
|
|
155
178
|
model: this.opts.model,
|
|
156
|
-
messages,
|
|
179
|
+
messages: toWireMessages(messages),
|
|
157
180
|
tools: tools.length > 0 ? tools : undefined,
|
|
158
181
|
temperature: this.opts.temperature,
|
|
159
182
|
max_tokens: this.opts.maxTokens,
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { Agent } from "./agent.js";
|
|
7
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
|
+
function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let body = "";
|
|
11
|
+
let size = 0;
|
|
12
|
+
req.on("data", (c) => {
|
|
13
|
+
size += c.byteLength;
|
|
14
|
+
if (size > maxBytes) {
|
|
15
|
+
reject(new Error(`request body too large (>${maxBytes} bytes)`));
|
|
16
|
+
req.destroy();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
body += c.toString("utf8");
|
|
20
|
+
});
|
|
21
|
+
req.on("end", () => resolve(body));
|
|
22
|
+
req.on("error", reject);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function sendJson(res, status, payload) {
|
|
26
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
27
|
+
res.end(JSON.stringify(payload));
|
|
28
|
+
}
|
|
29
|
+
function isAuthorized(req, token) {
|
|
30
|
+
if (!token)
|
|
31
|
+
return true;
|
|
32
|
+
const header = req.headers.authorization ?? "";
|
|
33
|
+
return header === `Bearer ${token}`;
|
|
34
|
+
}
|
|
35
|
+
export function startServer(baseConfig, opts = {}) {
|
|
36
|
+
const host = opts.host ?? "127.0.0.1";
|
|
37
|
+
const maxConcurrent = opts.maxConcurrent ?? 4;
|
|
38
|
+
const token = process.env.SCODER_SERVER_TOKEN || "";
|
|
39
|
+
/** id -> run record; Map preserves insertion order (= creation order). */
|
|
40
|
+
const runs = new Map();
|
|
41
|
+
const publicRun = (r) => ({
|
|
42
|
+
id: r.id,
|
|
43
|
+
task: r.task,
|
|
44
|
+
workdir: r.workdir,
|
|
45
|
+
status: r.status,
|
|
46
|
+
createdAt: r.createdAt,
|
|
47
|
+
finishedAt: r.finishedAt,
|
|
48
|
+
eventCount: r.events.length,
|
|
49
|
+
result: r.result,
|
|
50
|
+
error: r.error,
|
|
51
|
+
eventsUrl: `/api/runs/${r.id}/events`,
|
|
52
|
+
});
|
|
53
|
+
const runningCount = () => [...runs.values()].filter((r) => r.status === "running").length;
|
|
54
|
+
/** Launch an agent run in the background, wiring events into the record. */
|
|
55
|
+
const launchRun = (record, cfg, task) => {
|
|
56
|
+
const agent = new Agent(cfg);
|
|
57
|
+
record.agent = agent;
|
|
58
|
+
agent.onEvent = (ev) => {
|
|
59
|
+
record.events.push(ev);
|
|
60
|
+
if (ev.type === "finish") {
|
|
61
|
+
record.status = ev.success ? "success" : record.abortRequested ? "aborted" : "failed";
|
|
62
|
+
record.finishedAt = new Date().toISOString();
|
|
63
|
+
}
|
|
64
|
+
for (const sub of record.subscribers)
|
|
65
|
+
sub();
|
|
66
|
+
};
|
|
67
|
+
agent
|
|
68
|
+
.run(task)
|
|
69
|
+
.then((result) => {
|
|
70
|
+
record.result = result;
|
|
71
|
+
if (record.status === "running") {
|
|
72
|
+
// Should not happen (finish event always arrives first), but be safe.
|
|
73
|
+
record.status = result.success ? "success" : record.abortRequested ? "aborted" : "failed";
|
|
74
|
+
record.finishedAt = new Date().toISOString();
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
record.error = err.message;
|
|
79
|
+
// Synthetic terminal events so SSE clients always see a stream end.
|
|
80
|
+
record.events.push({ type: "error", message: record.error });
|
|
81
|
+
record.events.push({ type: "finish", success: false, steps: 0, summary: `run failed: ${record.error}` });
|
|
82
|
+
record.status = "error";
|
|
83
|
+
record.finishedAt = new Date().toISOString();
|
|
84
|
+
for (const sub of record.subscribers)
|
|
85
|
+
sub();
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const handleEventsSSE = (req, res, record) => {
|
|
89
|
+
res.writeHead(200, {
|
|
90
|
+
"Content-Type": "text/event-stream",
|
|
91
|
+
"Cache-Control": "no-cache",
|
|
92
|
+
Connection: "keep-alive",
|
|
93
|
+
});
|
|
94
|
+
let sent = 0;
|
|
95
|
+
const heartbeat = setInterval(() => {
|
|
96
|
+
if (!res.writableEnded)
|
|
97
|
+
res.write(": ping\n\n");
|
|
98
|
+
}, 15_000);
|
|
99
|
+
heartbeat.unref?.();
|
|
100
|
+
/** Replay buffered events + live ones; ends the stream on a terminal event. */
|
|
101
|
+
const flush = () => {
|
|
102
|
+
while (sent < record.events.length) {
|
|
103
|
+
const ev = record.events[sent++];
|
|
104
|
+
res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
105
|
+
if (ev.type === "finish" || ev.type === "error") {
|
|
106
|
+
res.end();
|
|
107
|
+
record.subscribers.delete(flush);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
record.subscribers.add(flush);
|
|
113
|
+
flush(); // replays the buffer, then stays live via the subscription
|
|
114
|
+
req.on("close", () => {
|
|
115
|
+
record.subscribers.delete(flush);
|
|
116
|
+
clearInterval(heartbeat);
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const handle = async (req, res) => {
|
|
120
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
121
|
+
const path = url.pathname;
|
|
122
|
+
if (!isAuthorized(req, token)) {
|
|
123
|
+
sendJson(res, 401, { error: "unauthorized (set Authorization: Bearer <SCODER_SERVER_TOKEN>)" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// ── GET /api/health ──
|
|
127
|
+
if (path === "/api/health" && req.method === "GET") {
|
|
128
|
+
sendJson(res, 200, { ok: true, running: runningCount(), runs: runs.size });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// ── /api/runs ──
|
|
132
|
+
if (path === "/api/runs") {
|
|
133
|
+
if (req.method === "GET") {
|
|
134
|
+
sendJson(res, 200, { runs: [...runs.values()].map(publicRun) });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.method === "POST") {
|
|
138
|
+
if (runningCount() >= maxConcurrent) {
|
|
139
|
+
sendJson(res, 429, { error: `too many concurrent runs (max ${maxConcurrent}) — retry later` });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
body = JSON.parse(await readBody(req));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
sendJson(res, 400, { error: err.message });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (typeof body.task !== "string" || body.task.trim().length === 0) {
|
|
151
|
+
sendJson(res, 400, { error: 'field "task" (non-empty string) is required' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const cfg = { ...baseConfig, yes: true };
|
|
155
|
+
if (body.workdir) {
|
|
156
|
+
const abs = resolve(body.workdir);
|
|
157
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
158
|
+
sendJson(res, 400, { error: `workdir does not exist or is not a directory: ${body.workdir}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
cfg.workdir = abs;
|
|
162
|
+
}
|
|
163
|
+
const permission = body.permission ?? cfg.permissionMode;
|
|
164
|
+
if (permission === "ask") {
|
|
165
|
+
sendJson(res, 400, { error: 'permission "ask" is not supported by the server (it cannot prompt). Use "auto" or "readonly".' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
cfg.permissionMode = permission;
|
|
169
|
+
if (body.buildCmd)
|
|
170
|
+
cfg.buildCmd = body.buildCmd;
|
|
171
|
+
if (body.model)
|
|
172
|
+
cfg.model = body.model;
|
|
173
|
+
if (body.provider)
|
|
174
|
+
cfg.provider = body.provider;
|
|
175
|
+
if (body.maxSteps)
|
|
176
|
+
cfg.maxSteps = Math.min(Math.max(body.maxSteps, 1), 500);
|
|
177
|
+
const record = {
|
|
178
|
+
id: randomUUID(),
|
|
179
|
+
task: body.task,
|
|
180
|
+
workdir: cfg.workdir,
|
|
181
|
+
status: "running",
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
finishedAt: null,
|
|
184
|
+
events: [],
|
|
185
|
+
subscribers: new Set(),
|
|
186
|
+
agent: null,
|
|
187
|
+
abortRequested: false,
|
|
188
|
+
result: null,
|
|
189
|
+
error: null,
|
|
190
|
+
};
|
|
191
|
+
runs.set(record.id, record);
|
|
192
|
+
launchRun(record, cfg, body.task);
|
|
193
|
+
sendJson(res, 202, publicRun(record));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// ── /api/runs/:id[...] ──
|
|
200
|
+
const runMatch = path.match(/^\/api\/runs\/([0-9a-f-]{36})(\/events|\/abort)?$/i);
|
|
201
|
+
if (runMatch) {
|
|
202
|
+
const record = runs.get(runMatch[1]);
|
|
203
|
+
if (!record) {
|
|
204
|
+
sendJson(res, 404, { error: `unknown run id: ${runMatch[1]}` });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (runMatch[2] === "/events" && req.method === "GET") {
|
|
208
|
+
handleEventsSSE(req, res, record);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (runMatch[2] === "/abort" && req.method === "POST") {
|
|
212
|
+
if (record.status !== "running" || !record.agent) {
|
|
213
|
+
sendJson(res, 409, { error: `run is not running (status: ${record.status})` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
record.abortRequested = true;
|
|
217
|
+
record.agent.abort();
|
|
218
|
+
sendJson(res, 200, { ok: true, status: "running (aborting...)" });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!runMatch[2] && req.method === "GET") {
|
|
222
|
+
sendJson(res, 200, publicRun(record));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
sendJson(res, 404, { error: `not found: ${req.method} ${path}` });
|
|
227
|
+
};
|
|
228
|
+
const server = createServer((req, res) => {
|
|
229
|
+
void handle(req, res).catch(() => {
|
|
230
|
+
if (!res.writableEnded)
|
|
231
|
+
sendJson(res, 500, { error: "internal server error" });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
server.once("error", reject);
|
|
236
|
+
server.listen(opts.port ?? 0, host, () => {
|
|
237
|
+
const address = server.address();
|
|
238
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
239
|
+
resolve({
|
|
240
|
+
server,
|
|
241
|
+
port,
|
|
242
|
+
close: () => new Promise((res) => {
|
|
243
|
+
// Abort in-flight runs so background work does not keep the loop alive.
|
|
244
|
+
for (const r of runs.values()) {
|
|
245
|
+
if (r.status === "running") {
|
|
246
|
+
r.abortRequested = true;
|
|
247
|
+
r.agent?.abort();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
server.close(() => res());
|
|
251
|
+
server.closeAllConnections?.();
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { resolve, dirname, basename, join, relative, isAbsolute } from "node:path";
|
|
4
4
|
import { unifiedDiff } from "./diff.js";
|
|
5
5
|
import * as ui from "./ui.js";
|
|
6
|
+
import { toolWebFetch } from "./webfetch.js";
|
|
6
7
|
const MAX_OUTPUT_CHARS = 20_000;
|
|
7
8
|
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
8
9
|
function clamp(s, max = MAX_OUTPUT_CHARS) {
|
|
@@ -88,16 +89,21 @@ function toolReadFile(workdir, args) {
|
|
|
88
89
|
return { ok: true, output: clamp(numbered) };
|
|
89
90
|
});
|
|
90
91
|
}
|
|
92
|
+
function toToolDiff(path, diff) {
|
|
93
|
+
if (!diff.text && !diff.truncated)
|
|
94
|
+
return undefined;
|
|
95
|
+
return { path, text: diff.text, added: diff.added, removed: diff.removed, truncated: diff.truncated };
|
|
96
|
+
}
|
|
91
97
|
/** Shared permission/diff gate for file writes. Returns null = approved. */
|
|
92
|
-
async function gateWrite(ctx, path,
|
|
98
|
+
async function gateWrite(ctx, path, diff) {
|
|
93
99
|
const mode = ctx.permissionMode ?? "auto";
|
|
94
|
-
const diff = unifiedDiff(path, oldContent, newContent);
|
|
95
100
|
if (mode === "readonly") {
|
|
96
101
|
if (diff.text)
|
|
97
102
|
ui.printDiff(diff.text);
|
|
98
103
|
return {
|
|
99
104
|
ok: false,
|
|
100
105
|
output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
|
|
106
|
+
diff: toToolDiff(path, diff),
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
if (mode === "ask" && ctx.permissions) {
|
|
@@ -105,7 +111,11 @@ async function gateWrite(ctx, path, oldContent, newContent) {
|
|
|
105
111
|
ui.printDiff(diff.text);
|
|
106
112
|
const allowed = await ctx.permissions.confirmEdit(path, diff.text);
|
|
107
113
|
if (!allowed) {
|
|
108
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.`,
|
|
117
|
+
diff: toToolDiff(path, diff),
|
|
118
|
+
};
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
else if (diff.text && !diff.truncated) {
|
|
@@ -131,12 +141,17 @@ async function toolWriteFile(ctx, args) {
|
|
|
131
141
|
catch {
|
|
132
142
|
// new file
|
|
133
143
|
}
|
|
134
|
-
const
|
|
144
|
+
const diff = unifiedDiff(args.path, oldContent, args.content);
|
|
145
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
135
146
|
if (gate)
|
|
136
147
|
return gate;
|
|
137
148
|
mkdirSync(dirname(abs), { recursive: true });
|
|
138
149
|
writeFileSync(abs, args.content, "utf8");
|
|
139
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
output: `Wrote ${args.content.split("\n").length} lines to ${args.path}`,
|
|
153
|
+
diff: toToolDiff(args.path, diff),
|
|
154
|
+
};
|
|
140
155
|
}
|
|
141
156
|
async function toolEditFile(ctx, args) {
|
|
142
157
|
let abs;
|
|
@@ -157,11 +172,16 @@ async function toolEditFile(ctx, args) {
|
|
|
157
172
|
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
158
173
|
// which would corrupt literal text like "$&" in the new content.
|
|
159
174
|
const newContent = content.replace(args.old_string, () => args.new_string);
|
|
160
|
-
const
|
|
175
|
+
const diff = unifiedDiff(args.path, content, newContent);
|
|
176
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
161
177
|
if (gate)
|
|
162
178
|
return gate;
|
|
163
179
|
writeFileSync(abs, newContent, "utf8");
|
|
164
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
output: `Edited ${args.path}`,
|
|
183
|
+
diff: toToolDiff(args.path, diff),
|
|
184
|
+
};
|
|
165
185
|
}
|
|
166
186
|
function toolListFiles(workdir, args) {
|
|
167
187
|
return toResult(() => {
|
|
@@ -211,8 +231,10 @@ function toolListFiles(workdir, args) {
|
|
|
211
231
|
* Run a shell command asynchronously (async spawn keeps the event loop free,
|
|
212
232
|
* so streaming output renders and SIGINT is handled promptly).
|
|
213
233
|
* Kills the child on timeout or external abort (SIGINT via ctx.signal).
|
|
234
|
+
* When `onOutput` is provided, stdout/stderr chunks are forwarded live as they
|
|
235
|
+
* arrive (in addition to being collected into the final ToolResult).
|
|
214
236
|
*/
|
|
215
|
-
export async function toolRunCommand(workdir, args, signal) {
|
|
237
|
+
export async function toolRunCommand(workdir, args, signal, onOutput) {
|
|
216
238
|
const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
|
|
217
239
|
return new Promise((resolve) => {
|
|
218
240
|
let settled = false;
|
|
@@ -252,8 +274,16 @@ export async function toolRunCommand(workdir, args, signal) {
|
|
|
252
274
|
onAbort();
|
|
253
275
|
else
|
|
254
276
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
255
|
-
child.stdout?.on("data", (c) => {
|
|
256
|
-
|
|
277
|
+
child.stdout?.on("data", (c) => {
|
|
278
|
+
const s = c.toString("utf8");
|
|
279
|
+
stdout += s;
|
|
280
|
+
onOutput?.("stdout", s);
|
|
281
|
+
});
|
|
282
|
+
child.stderr?.on("data", (c) => {
|
|
283
|
+
const s = c.toString("utf8");
|
|
284
|
+
stderr += s;
|
|
285
|
+
onOutput?.("stderr", s);
|
|
286
|
+
});
|
|
257
287
|
child.on("error", (err) => {
|
|
258
288
|
finish({ ok: false, output: clamp(`$ ${args.command}\n(spawn error) ${err.message}`) });
|
|
259
289
|
});
|
|
@@ -353,7 +383,28 @@ export const AGENT_TOOLS = [
|
|
|
353
383
|
}, ["command"]),
|
|
354
384
|
},
|
|
355
385
|
},
|
|
356
|
-
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal),
|
|
386
|
+
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal, ctx.onToolOutput ? (stream, delta) => ctx.onToolOutput?.("run_command", stream, delta) : undefined),
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
spec: {
|
|
390
|
+
type: "function",
|
|
391
|
+
function: {
|
|
392
|
+
name: "web_fetch",
|
|
393
|
+
description: "Fetch a public HTTP(S) URL and return its content as readable plain text " +
|
|
394
|
+
"(HTML converted to text, JSON pretty-printed). WHEN TO USE: the task depends on external, " +
|
|
395
|
+
"possibly outdated knowledge — library/API documentation, an error message or comment " +
|
|
396
|
+
"containing a URL, current package versions or changelogs, standards/specs. " +
|
|
397
|
+
"WHEN NOT TO: the answer is already in the working directory or in the user's request; " +
|
|
398
|
+
"you need a search engine (this tool fetches exact URLs only, it cannot search); " +
|
|
399
|
+
"the content is a binary file (images, archives, PDFs are refused). " +
|
|
400
|
+
"Private/internal addresses (localhost, 10.x, 192.168.x, 127.x) are blocked.",
|
|
401
|
+
parameters: obj({
|
|
402
|
+
url: str("Absolute http(s) URL to fetch"),
|
|
403
|
+
max_chars: str("Max characters of extracted content to return (default 20000, max 100000)"),
|
|
404
|
+
}, ["url"]),
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
run: (ctx, args) => toolWebFetch(ctx, args),
|
|
357
408
|
},
|
|
358
409
|
];
|
|
359
410
|
export function getTool(name) {
|