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/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
- const agent = new Agent(cfg);
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, oldContent, newContent) {
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 { ok: false, output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.` };
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 gate = await gateWrite(ctx, args.path, oldContent, args.content);
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 { ok: true, output: `Wrote ${args.content.split("\n").length} lines to ${args.path}` };
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 gate = await gateWrite(ctx, args.path, content, newContent);
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 { ok: true, output: `Edited ${args.path}` };
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) => { stdout += c.toString("utf8"); });
256
- child.stderr?.on("data", (c) => { stderr += c.toString("utf8"); });
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) {