s-app-scoder-agent 0.3.0 → 0.5.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 CHANGED
@@ -80,9 +80,22 @@ You (task) → LLM plans → Tools execute code → Build runs → Success? →
80
80
  | `edit_file` | Find-and-replace (unique match required) |
81
81
  | `list_files` | Recursive directory listing |
82
82
  | `run_command` | Execute any shell command (subject to permission policy) |
83
+ | `web_fetch` | Fetch a public HTTP(S) URL → readable text (HTML→text, JSON pretty-printed) |
83
84
 
84
85
  File writes (`write_file` / `edit_file`) are symlink-safe: paths are resolved via `realpath` and cannot escape the working directory, even through a malicious symlink. `read_file` refuses files larger than 2MB with guidance to use `grep`/`head`/`tail`.
85
86
 
87
+ ### Web access (`web_fetch`)
88
+
89
+ The agent can read the internet through the built-in `web_fetch` tool — it fetches exact URLs (it cannot search) and returns readable plain text: HTML is converted to text (scripts/styles stripped, entities decoded, title extracted), JSON is pretty-printed, binary content is refused. The system prompt instructs the agent *when to fetch* (external/API docs, URLs in error messages, current versions, changelogs) and when not to (answer available locally).
90
+
91
+ Safety defaults:
92
+
93
+ - **SSRF protection** — `localhost`, private and link-local addresses (10.x, 172.16/12, 192.168.x, 127.x, 169.254.x, IPv6 ULA/link-local) are blocked. Set `SCODER_WEB_ALLOW_PRIVATE=1` to allow local dev servers.
94
+ - Only `http`/`https`; up to 5 redirects — every hop is re-validated against the private-address block list.
95
+ - 30 s timeout, SIGINT-abortable; body capped at 512 KB; extracted text capped at 20k chars (configurable via `max_chars`).
96
+ - HTML is converted to plain text (no JS execution); images/archives/PDFs are refused.
97
+ - Read-only — the tool never writes locally, so it is allowed even in `readonly` permission mode.
98
+
86
99
  ## Configuration
87
100
 
88
101
  ### Environment variables
@@ -96,6 +109,9 @@ File writes (`write_file` / `edit_file`) are symlink-safe: paths are resolved vi
96
109
  | `SCODER_MAX_STEPS` | `40` | Max loop iterations before giving up |
97
110
  | `SCODER_PERMISSION` | `auto` | Permission mode: `auto` \| `ask` \| `readonly` |
98
111
  | `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
112
+ | `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
113
+ | `SCODER_SERVER_TOKEN` | — | `scoder serve` only: require `Authorization: Bearer <token>` |
114
+ | `SCODER_WEB_ALLOW_PRIVATE` | — | Set to `1` to let `web_fetch` reach localhost/private addresses (local dev) |
99
115
  | `SCODER_NO_APIKEY_WARNING` | — | Set to `1` to suppress the apiKey-in-rc warning |
100
116
 
101
117
  ### Per-project config file (.scoderrc.json)
@@ -225,7 +241,39 @@ Sessions older than 7 days are pruned automatically on the next run. Use `--no-s
225
241
 
226
242
  ## Streaming
227
243
 
228
- LLM output is streamed token-by-token by default (`SCODER_STREAM=0` to disable). If a provider doesn't support streaming, scoder automatically falls back to non-streaming requests.
244
+ scoder streams everything it can, live, as it happens:
245
+
246
+ - **LLM tokens** — assistant text renders character-by-character (OpenAI-compatible SSE and native Anthropic streaming). If a provider doesn't support streaming, scoder automatically falls back to non-streaming requests. Disable with `SCODER_STREAM=0`.
247
+ - **Tool output** — `run_command` and build output print live (dimmed block) while the command runs, instead of appearing only after it finishes. Live output is capped at 20k chars; the full output still goes to the LLM.
248
+ - **SIGINT-safe** — Ctrl+C kills the running command immediately; streamed rendering never blocks the event loop.
249
+ - Output honors `NO_COLOR` and degrades to plain text when piped (CI-friendly).
250
+
251
+ ## HTTP Server Mode (`scoder serve`)
252
+
253
+ Run scoder as a local HTTP service — submit tasks programmatically and consume progress as Server-Sent Events:
254
+
255
+ ```bash
256
+ scoder serve # http://127.0.0.1:0 (random port, printed at startup)
257
+ scoder serve --port 4114 # fixed port
258
+ SCODER_SERVER_TOKEN=secret scoder serve --host 127.0.0.1
259
+ ```
260
+
261
+ | Endpoint | Method | Description |
262
+ |----------|--------|-------------|
263
+ | `/api/health` | GET | Liveness: `{ ok, running, runs }` |
264
+ | `/api/runs` | POST | Start a run: `{ "task": "...", "workdir?", "buildCmd?", "model?", "provider?", "maxSteps?", "permission?" }` → `202 { id, eventsUrl }` |
265
+ | `/api/runs` | GET | List runs with status/timing |
266
+ | `/api/runs/<id>` | GET | Run status + final result |
267
+ | `/api/runs/<id>/events` | GET | **SSE stream** — full event replay, then live, closes on finish |
268
+ | `/api/runs/<id>/abort` | POST | Abort a running run (equivalent to SIGINT) |
269
+
270
+ SSE event types: `run_start`, `step`, `llm_delta` (assistant text), `tool_call`, `tool_output` (live stdout/stderr chunks), `tool_result`, `llm_usage`, `build`, `finish`. The stream replays every event from the beginning, then stays live, and closes after `finish` — safe to (re)connect at any time.
271
+
272
+ Notes:
273
+
274
+ - Runs are always non-interactive; `permission: "ask"` is rejected — use `auto` or `readonly`.
275
+ - Binds `127.0.0.1` by default. Set `SCODER_SERVER_TOKEN` to require `Authorization: Bearer <token>` on every request.
276
+ - Max 4 concurrent runs (extra requests get `429`); per-run overrides: `workdir`, `buildCmd`, `model`, `provider`, `maxSteps`, `permission`.
229
277
 
230
278
  ## Logging
231
279
 
@@ -268,6 +316,7 @@ scoder detects the build command automatically:
268
316
  ```
269
317
  scoder [options] "<task>" Run a task autonomously until the build passes
270
318
  scoder Start interactive session
319
+ scoder serve [--port] [--host] HTTP server mode (see above)
271
320
  scoder --help | --version Help and version
272
321
  ```
273
322
 
@@ -283,6 +332,9 @@ scoder --help | --version Help and version
283
332
  | `-y` | Non-interactive mode (denies commands needing confirmation) |
284
333
  | `--provider <name>` | `openai` \| `anthropic` (env SCODER_PROVIDER; auto-detected) |
285
334
  | `--no-save` | Disable session auto-save to `.scoder/sessions/` |
335
+ | `--log-file` | Write a structured JSONL event log to `.scoder/logs/` |
336
+ | `--port <n>` | (`serve`) TCP port, `0` = random free port |
337
+ | `--host <addr>` | (`serve`) Bind address (default `127.0.0.1`) |
286
338
 
287
339
  ## Examples
288
340
 
@@ -305,6 +357,15 @@ scoder -m claude-sonnet-4-5 "refactor the parser module"
305
357
 
306
358
  # CI: non-interactive, no session saving
307
359
  scoder --no-save -y "run the full migration"
360
+
361
+ # Run as an HTTP service and consume progress via SSE
362
+ scoder serve --port 4114 &
363
+ curl -s -X POST localhost:4114/api/runs -H 'Content-Type: application/json' \
364
+ -d '{"task": "add input validation to the CLI"}'
365
+ curl -N http://127.0.0.1:4114/api/runs/<id>/events # live progress
366
+
367
+ # Fetch external docs during a task (agent decides when, via web_fetch)
368
+ scoder "upgrade express to v5 — check the official migration guide first"
308
369
  ```
309
370
 
310
371
  ## Architecture
@@ -312,22 +373,26 @@ scoder --no-save -y "run the full migration"
312
373
  ```
313
374
  ┌─────────────────────────────────────────────┐
314
375
  │ CLI (src/index.ts) │
315
- │ arg parsing, REPL, SIGINT handling │
316
- └─────────────────┬───────────────────────────┘
317
- │
318
- ┌─────────────────▼───────────────────────────┐
376
+ │ arg parsing, REPL, serve, SIGINT │
377
+ └───────────────┬─────────────────────────────┘
378
+ │
379
+ ┌───────────────▼─────────────────────────────┐
319
380
  │ Agent (src/agent.ts) │
320
- │ Closed-loop orchestrator │
321
- │ history trimming, error injection │
322
- └───────┬───────────────────────┬─────────────┘
323
- │ │
324
- ┌───────▼───────┐ ┌─────────▼──────────┐
325
- │ LLM Client │ │ Tool Registry │
326
- │ (src/llm.ts) │ │ (src/tools.ts) │
327
- │ OpenAI compat │ │ read/write/run/list │
328
- └───────────────┘ └──────────────────────┘
329
- │
330
- ▼
381
+ │ Closed-loop orchestrator │
382
+ │ history trimming, error injection │
383
+ │ emits AgentEvent (UI / SSE consumers) │
384
+ └──────┬─────────────┬───────────────┬────────┘
385
+ │ │ │
386
+ ┌──────▼───────┐ ┌──▼───────────┐ ┌▼──────────────────┐
387
+ │ LLM Client │ │ Tool Registry│ │ Server │
388
+ │ (src/llm.ts) │ │(src/tools.ts)│ │ (src/server.ts) │
389
+ │ OpenAI compat│ │ + web_fetch │ │ HTTP API + SSE │
390
+ │ (anthropic: │ │ (src/ │ │ runs in background│
391
+ │ provider- │ │ webfetch.ts)│ └───────────────────┘
392
+ │ anthropic) │ └──────────────┘
393
+ └──────────────┘
394
+ │
395
+ ▼
331
396
  ┌─────────────────────────────────────────────┐
332
397
  │ Build Checker (src/build.ts) │
333
398
  │ run build → exit 0 = stop condition met │
@@ -337,7 +402,7 @@ scoder --no-save -y "run the full migration"
337
402
  ## Integration Testing
338
403
 
339
404
  ```bash
340
- npm test # runs: lint → typecheck → build → 116 unit tests + e2e + MCP smoke
405
+ npm test # runs: build → unit tests (node --test) → e2e closed-loop → MCP smoke
341
406
  npm run lint # eslint flat config (typescript-eslint + eqeqeq, prefer-const...)
342
407
  npm run typecheck # tsc --noEmit
343
408
  ```
@@ -345,17 +410,18 @@ npm run typecheck # tsc --noEmit
345
410
  Test suites (run with `npm test` — auto-discovers `test/*.test.mjs`):
346
411
  - `test/permissions.test.mjs` — catastrophic patterns, mode classification, allowlist word-boundary, timeout-denies
347
412
  - `test/permission-prompt.test.mjs` — interactive prompt timeout behavior
348
- - `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list
413
+ - `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list, live `run_command` output streaming
414
+ - `test/webfetch.test.mjs` — HTML→text, JSON, redirects, SSRF guard, binary refusal, truncation
415
+ - `test/server.test.mjs` — HTTP API: run lifecycle via SSE, abort, auth, live `tool_output` events
349
416
  - `test/core.test.mjs` — unified diff, retry backoff, context trimming
350
417
  - `test/config.test.mjs` — env/rc/cli precedence, logFile mapping, permission lists
351
418
  - `test/session.test.mjs` — session store, smart prune, resolve
419
+ - `test/cost.test.mjs` — token usage & cost aggregation
352
420
  - `test/anthropic.test.mjs` — message translation + native SSE streaming
353
421
  - `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction
354
422
  - `test/run-e2e.mjs` — closed-loop integration (mock LLM server)
355
423
  - `test/run-mcp-test.mjs` — MCP smoke (mock stdio server)
356
424
 
357
- Coverage (lines): **90.3%** overall — see `npm run coverage`.
358
-
359
425
  ## Exit Codes
360
426
 
361
427
  | Code | Meaning |
package/dist/agent.js CHANGED
@@ -28,7 +28,13 @@ RULES:
28
28
  6. Never invent requirements beyond the given task.
29
29
  7. IMPORTANT: if a run_command is denied by permission policy, you will receive
30
30
  an error message. Use this as a signal to find a safer alternative or to
31
- communicate the limitation to the user.`;
31
+ communicate the limitation to the user.
32
+ 8. You have web access ONLY through web_fetch (it fetches exact URLs, it cannot
33
+ search). Use it when the task depends on external or fast-changing knowledge:
34
+ library/API documentation, a URL found in an error message or code comment,
35
+ current package versions, changelogs, release notes, standards. Skip it when
36
+ the answer is in the working directory or is stable general knowledge. After
37
+ fetching, ground your edits in what the page actually says.`;
32
38
  export class Agent {
33
39
  config;
34
40
  client;
@@ -43,9 +49,15 @@ export class Agent {
43
49
  sessionCreatedAt;
44
50
  currentTask = "";
45
51
  lastBuildResult = null;
52
+ /** Accumulated token usage across the current run (session state). */
53
+ usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
46
54
  mcp = null;
47
55
  /** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
48
56
  runSignal = new AbortController();
57
+ /** Optional structured-event sink (used by the HTTP/SSE server frontend). */
58
+ onEvent = null;
59
+ /** Set while a tool call is executing, for tool_output event correlation. */
60
+ currentToolCall = null;
49
61
  constructor(config) {
50
62
  this.config = config;
51
63
  const commonOpts = {
@@ -70,6 +82,7 @@ export class Agent {
70
82
  workdir: config.workdir,
71
83
  permissions: this.permissions,
72
84
  permissionMode: config.permissionMode,
85
+ onToolOutput: this.handleToolOutput,
73
86
  };
74
87
  this.session = new SessionStore(config.workdir, config.noSave !== true);
75
88
  this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
@@ -90,6 +103,81 @@ export class Agent {
90
103
  // terminate its child process immediately (long builds must be interruptible).
91
104
  this.ctx.signal = this.runSignal.signal;
92
105
  }
106
+ /** Emit one structured run event to the sink (if attached). */
107
+ emit(ev) {
108
+ this.onEvent?.(ev);
109
+ }
110
+ /**
111
+ * Live tool-output handler: renders chunks in the terminal (dimmed block) and
112
+ * forwards them as `tool_output` events. Opens the stream block lazily on the
113
+ * first chunk; `endToolStream()` closes it after the tool completes.
114
+ */
115
+ handleToolOutput = (name, stream, delta) => {
116
+ if (!ui.isToolStreamOpen()) {
117
+ ui.toolStreamStart();
118
+ }
119
+ ui.toolStreamChunk(delta);
120
+ this.emit({ type: "tool_output", name, stream, delta });
121
+ };
122
+ /** Close the live tool-output block (if open) before printing the next UI element. */
123
+ endToolStream() {
124
+ if (ui.isToolStreamOpen())
125
+ ui.toolStreamEnd();
126
+ }
127
+ /** Push one tool-result message back into the conversation. */
128
+ pushToolResult(tc, content) {
129
+ this.messages.push({
130
+ role: "tool",
131
+ tool_call_id: tc.id,
132
+ name: tc.name,
133
+ content,
134
+ });
135
+ }
136
+ /**
137
+ * Handle a rejected tool call (unknown tool, policy denial, user denial):
138
+ * render the failure, log the event, and push an ERROR tool message so the
139
+ * LLM can react. Replaces the previously duplicated denied/deny/unknown blocks.
140
+ */
141
+ rejectToolCall(step, tc, output, logExtra) {
142
+ ui.toolResult(tc.name, false, output);
143
+ this.emit({ type: "tool_result", step, name: tc.name, ok: false, output });
144
+ this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
145
+ this.pushToolResult(tc, output);
146
+ }
147
+ /**
148
+ * Record usage of one LLM call: render token counts, log an `llm_usage`
149
+ * event, and accumulate totals into session state.
150
+ */
151
+ recordLlmUsage(usage) {
152
+ if (!usage)
153
+ return;
154
+ const input = usage.prompt_tokens ?? 0;
155
+ const output = usage.completion_tokens ?? 0;
156
+ const cached = usage.cached_tokens ?? 0;
157
+ this.usageTotals.calls += 1;
158
+ this.usageTotals.inputTokens += input;
159
+ this.usageTotals.outputTokens += output;
160
+ this.usageTotals.cachedTokens += cached;
161
+ ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
162
+ this.emit({
163
+ type: "llm_usage",
164
+ usage: { input_tokens: input, output_tokens: output, cached_tokens: cached },
165
+ });
166
+ this.logger.log({
167
+ type: "llm_usage",
168
+ model: this.client.model,
169
+ provider: this.config.provider,
170
+ usage: {
171
+ input_tokens: input,
172
+ output_tokens: output,
173
+ cached_tokens: cached,
174
+ },
175
+ });
176
+ }
177
+ /** Reset per-run usage accumulation (called when a fresh run starts). */
178
+ resetUsageTotals() {
179
+ this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
180
+ }
93
181
  /**
94
182
  * One LLM turn: streaming if enabled (renders deltas live), with automatic
95
183
  * fallback to non-streaming when the provider/stream fails.
@@ -110,6 +198,7 @@ export class Agent {
110
198
  started = true;
111
199
  }
112
200
  ui.llmStreamChunk(chunk.contentDelta);
201
+ this.emit({ type: "llm_delta", delta: chunk.contentDelta });
113
202
  streamedText += chunk.contentDelta;
114
203
  }
115
204
  if (chunk.final)
@@ -166,11 +255,16 @@ export class Agent {
166
255
  throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
167
256
  }
168
257
  const buildCmd = this.buildCmd;
169
- ui.header("TASK");
170
- ui.info(task);
171
- ui.info(`model=${this.client.model} workdir=${this.config.workdir} build="${buildCmd}" maxSteps=${this.config.maxSteps}`);
258
+ ui.banner("scoder", [
259
+ ["task", task],
260
+ ["model", `${this.client.model} (${this.config.provider}${this.config.stream ? ", stream" : ""})`],
261
+ ["workdir", this.config.workdir],
262
+ ["build", buildCmd],
263
+ ["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
264
+ ]);
172
265
  this.currentTask = task;
173
266
  this.resetRunSignal();
267
+ this.resetUsageTotals();
174
268
  this.messages = [
175
269
  {
176
270
  role: "system",
@@ -179,6 +273,14 @@ export class Agent {
179
273
  },
180
274
  { role: "user", content: task },
181
275
  ];
276
+ this.emit({
277
+ type: "run_start",
278
+ task,
279
+ model: this.client.model,
280
+ provider: this.config.provider,
281
+ workdir: this.config.workdir,
282
+ buildCmd,
283
+ });
182
284
  try {
183
285
  await this.connectMcp();
184
286
  return await this.runLoop(0, buildCmd, null);
@@ -199,6 +301,7 @@ export class Agent {
199
301
  const buildCmd = this.buildCmd;
200
302
  this.currentTask = `${this.currentTask}\n${task}`.trim();
201
303
  this.resetRunSignal();
304
+ this.resetUsageTotals();
202
305
  this.messages.push({ role: "user", content: task });
203
306
  try {
204
307
  await this.connectMcp();
@@ -217,6 +320,7 @@ export class Agent {
217
320
  this.sessionCreatedAt = session.createdAt;
218
321
  this.currentTask = session.task;
219
322
  this.resetRunSignal();
323
+ this.resetUsageTotals();
220
324
  this.messages = session.messages ?? [];
221
325
  ui.header(`RESUME ${session.id}`);
222
326
  ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
@@ -250,6 +354,7 @@ export class Agent {
250
354
  while (steps < this.config.maxSteps && !this.aborted) {
251
355
  steps++;
252
356
  ui.step(steps, this.config.maxSteps, "thinking...");
357
+ this.emit({ type: "step", step: steps, maxSteps: this.config.maxSteps });
253
358
  const { message, usage } = await this.chatWithStream().catch((err) => {
254
359
  // SIGINT during the LLM call — stop gracefully instead of crashing
255
360
  if (this.aborted) {
@@ -262,19 +367,13 @@ export class Agent {
262
367
  });
263
368
  if (this.aborted)
264
369
  break;
265
- if (usage) {
266
- ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
267
- this.logger.log({ type: "step", step: steps, usage });
268
- }
269
- else {
270
- this.logger.log({ type: "step", step: steps });
271
- }
370
+ this.recordLlmUsage(usage);
371
+ this.logger.log({ type: "step", step: steps });
272
372
  // No tool calls => assistant produced a text answer
273
373
  if (!message.tool_calls || message.tool_calls.length === 0) {
274
374
  const text = message.content ?? "";
275
375
  this.messages.push({ role: "assistant", content: text });
276
376
  // If the assistant says it's done without building, force a build check.
277
- // If the assistant says it's done without building, force a build check.
278
377
  if (claimsCompletion(text) && steps < this.config.maxSteps) {
279
378
  ui.info("agent claims completion -> verifying with build");
280
379
  lastBuild = await this.checkBuild(buildCmd);
@@ -311,24 +410,14 @@ export class Agent {
311
410
  args = tc.arguments ? JSON.parse(tc.arguments) : {};
312
411
  }
313
412
  catch {
314
- this.messages.push({
315
- role: "tool",
316
- tool_call_id: tc.id,
317
- name: tc.name,
318
- content: `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`,
319
- });
413
+ this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
320
414
  continue;
321
415
  }
322
416
  ui.toolCall(tc.name, args);
417
+ this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
418
+ this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
323
419
  if (!tool && !(this.mcp?.hasTool(tc.name))) {
324
- ui.toolResult(tc.name, false, "unknown tool");
325
- this.logger.log({ type: "tool", step: steps, tool: tc.name, ok: false, error: "unknown tool" });
326
- this.messages.push({
327
- role: "tool",
328
- tool_call_id: tc.id,
329
- name: tc.name,
330
- content: `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`,
331
- });
420
+ this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
332
421
  continue;
333
422
  }
334
423
  let result;
@@ -341,14 +430,7 @@ export class Agent {
341
430
  ok: false,
342
431
  output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
343
432
  };
344
- ui.toolResult(tc.name, false, result.output);
345
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: decision.reason });
346
- this.messages.push({
347
- role: "tool",
348
- tool_call_id: tc.id,
349
- name: tc.name,
350
- content: result.output,
351
- });
433
+ this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
352
434
  continue;
353
435
  }
354
436
  if (decision.decision === "ask") {
@@ -358,14 +440,7 @@ export class Agent {
358
440
  ok: false,
359
441
  output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
360
442
  };
361
- ui.toolResult(tc.name, false, result.output);
362
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: "user" });
363
- this.messages.push({
364
- role: "tool",
365
- tool_call_id: tc.id,
366
- name: tc.name,
367
- content: result.output,
368
- });
443
+ this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
369
444
  continue;
370
445
  }
371
446
  }
@@ -404,14 +479,12 @@ export class Agent {
404
479
  this.aborted = true;
405
480
  ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
406
481
  }
482
+ this.endToolStream();
483
+ this.currentToolCall = null;
407
484
  ui.toolResult(tc.name, result.ok, result.output);
485
+ this.emit({ type: "tool_result", step: steps, name: tc.name, ok: result.ok, output: result.output });
408
486
  stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
409
- this.messages.push({
410
- role: "tool",
411
- tool_call_id: tc.id,
412
- name: tc.name,
413
- content: summariseOutput(result.output),
414
- });
487
+ this.pushToolResult(tc, summariseOutput(result.output));
415
488
  }
416
489
  // After a build-command run, treat success as the terminal condition.
417
490
  // Match the FULL build command (not just its first word): matching "npm"
@@ -456,12 +529,20 @@ export class Agent {
456
529
  lastBuild: lastBuild
457
530
  ? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
458
531
  : null,
532
+ usage: {
533
+ llmCalls: this.usageTotals.calls,
534
+ inputTokens: this.usageTotals.inputTokens,
535
+ outputTokens: this.usageTotals.outputTokens,
536
+ cachedTokens: this.usageTotals.cachedTokens,
537
+ },
459
538
  }, this.sessionCreatedAt);
460
539
  }
461
540
  /** Explicitly verify the build (independent of what the LLM claims). */
462
541
  async checkBuild(buildCmd) {
463
- const res = await runBuild(this.config.workdir, buildCmd);
542
+ const res = await runBuild(this.config.workdir, buildCmd, (stream, delta) => this.handleToolOutput("build", stream, delta));
543
+ this.endToolStream();
464
544
  ui.buildStatus(res.success, res.output);
545
+ this.emit({ type: "build", success: res.success, command: buildCmd, output: res.output });
465
546
  this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
466
547
  return res;
467
548
  }
@@ -472,6 +553,7 @@ export class Agent {
472
553
  else {
473
554
  ui.error(`Task NOT complete. ${summary}`);
474
555
  }
556
+ this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000) });
475
557
  this.logger.log({ type: "finish", success, steps });
476
558
  return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
477
559
  }
package/dist/build.js CHANGED
@@ -10,8 +10,9 @@ export function detectBuildCommand(workdir) {
10
10
  if (pkg.scripts?.build)
11
11
  return "npm run build";
12
12
  }
13
- catch {
14
- // fall through
13
+ catch (err) {
14
+ // malformed package.json -> fall through to the other detectors
15
+ console.error(`⚠ cannot parse package.json (${err.message}) — build command detection degraded`);
15
16
  }
16
17
  }
17
18
  if (existsSync(resolve(workdir, "Makefile")))
@@ -29,13 +30,10 @@ export function detectBuildCommand(workdir) {
29
30
  /**
30
31
  * Run the build command. This is the agent's stop condition:
31
32
  * exit code 0 == task complete.
33
+ * `onOutput` (optional) receives stdout/stderr chunks live while the build runs.
32
34
  */
33
- /**
34
- * Run the build command. This is the agent's stop condition:
35
- * exit code 0 == task complete.
36
- */
37
- export async function runBuild(workdir, command) {
38
- const res = await toolRunCommand(workdir, { command, timeout_secs: 600 });
35
+ export async function runBuild(workdir, command, onOutput) {
36
+ const res = await toolRunCommand(workdir, { command, timeout_secs: 600 }, undefined, onOutput);
39
37
  return {
40
38
  success: res.ok,
41
39
  command,
package/dist/config.js CHANGED
@@ -11,8 +11,9 @@ function loadRcFile(workdir) {
11
11
  try {
12
12
  return JSON.parse(readFileSync(p, "utf8"));
13
13
  }
14
- catch {
15
- // malformed rc file -> ignore
14
+ catch (err) {
15
+ // malformed rc file -> ignore it, but tell the user why their config is not applied
16
+ console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
16
17
  }
17
18
  }
18
19
  }
@@ -28,6 +29,28 @@ function parseList(raw) {
28
29
  return [];
29
30
  return raw.filter((x) => typeof x === "string");
30
31
  }
32
+ /** Validate the user-supplied pricing table; drop malformed entries with a warning. */
33
+ function parsePricing(raw) {
34
+ if (raw === undefined)
35
+ return undefined;
36
+ if (typeof raw !== "object" || raw === null) {
37
+ console.error("⚠ ignoring invalid modelPricing in config (expected an object)");
38
+ return undefined;
39
+ }
40
+ const out = {};
41
+ for (const key of Object.keys(raw)) {
42
+ const p = raw[key];
43
+ if (typeof p === "object" && p !== null &&
44
+ typeof p.input === "number" && Number.isFinite(p.input) &&
45
+ typeof p.output === "number" && Number.isFinite(p.output)) {
46
+ out[key] = { input: p.input, output: p.output };
47
+ }
48
+ else {
49
+ console.error(`⚠ ignoring invalid modelPricing entry "${key}" (expected { input: number, output: number })`);
50
+ }
51
+ }
52
+ return out;
53
+ }
31
54
  export function loadConfig(overrides = {}) {
32
55
  const workdir = overrides.workdir ?? process.cwd();
33
56
  const rc = loadRcFile(workdir);
@@ -74,6 +97,7 @@ export function loadConfig(overrides = {}) {
74
97
  stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
75
98
  permissionMode: overrides.permissionMode ??
76
99
  parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
100
+ modelPricing: parsePricing(rc.modelPricing),
77
101
  };
78
102
  // Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
79
103
  if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Central constants: model pricing for cost estimation.
3
+ *
4
+ * Prices are USD per 1M tokens (input / output), approximate list prices.
5
+ * They can be overridden per-project via the rc file:
6
+ *
7
+ * .scoderrc.json:
8
+ * { "modelPricing": { "my-model": { "input": 1.0, "output": 2.0 } } }
9
+ *
10
+ * Keys are matched as a PREFIX of the model name (case-insensitive), so
11
+ * "gpt-4o-mini" also covers "gpt-4o-mini-2024-07-18". More specific keys
12
+ * (longer prefix) win.
13
+ */
14
+ /** Fallback price when a model is unknown (deliberately conservative, not zero). */
15
+ export const DEFAULT_MODEL_PRICING = { input: 0.5, output: 1.5 };
16
+ /** Approximate list prices, USD per 1M tokens. Longest matching prefix wins. */
17
+ export const MODEL_PRICING = {
18
+ // OpenAI
19
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
20
+ "gpt-4o": { input: 2.5, output: 10 },
21
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
22
+ "gpt-4.1": { input: 2, output: 8 },
23
+ "gpt-4-turbo": { input: 10, output: 30 },
24
+ "gpt-4": { input: 30, output: 60 },
25
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
26
+ "o1": { input: 15, output: 60 },
27
+ "o1-mini": { input: 1.1, output: 4.4 },
28
+ "o3-mini": { input: 1.1, output: 4.4 },
29
+ "o3": { input: 2, output: 8 },
30
+ "o4-mini": { input: 1.1, output: 4.4 },
31
+ // Anthropic
32
+ "claude-opus-4": { input: 15, output: 75 },
33
+ "claude-sonnet-4": { input: 3, output: 15 },
34
+ "claude-haiku-4": { input: 0.8, output: 4 },
35
+ "claude-3-7-sonnet": { input: 3, output: 15 },
36
+ "claude-3-5-sonnet": { input: 3, output: 15 },
37
+ "claude-3-5-haiku": { input: 0.8, output: 4 },
38
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
39
+ "claude-3-opus": { input: 15, output: 75 },
40
+ // Popular local/OpenAI-compatible providers (mostly $0 or nominal)
41
+ "llama3": { input: 0, output: 0 },
42
+ "qwen": { input: 0, output: 0 },
43
+ "deepseek-chat": { input: 0.27, output: 1.1 },
44
+ "deepseek-reasoner": { input: 0.55, output: 2.19 },
45
+ };
46
+ /**
47
+ * Resolve the price table for a model, honouring user overrides.
48
+ * `overrides` maps model-name prefixes to pricing and always wins over the
49
+ * built-in table. Longest matching prefix wins (case-insensitive).
50
+ */
51
+ export function resolveModelPricing(model, overrides = {}) {
52
+ const m = model.toLowerCase();
53
+ let best = null;
54
+ // Built-in table first (longest prefix wins), then overrides — an override
55
+ // with the SAME prefix length replaces the built-in entry.
56
+ for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
57
+ const k = key.toLowerCase();
58
+ if (m.startsWith(k) && (!best || k.length > best.key.length)) {
59
+ best = { key: k, pricing };
60
+ }
61
+ }
62
+ for (const [key, pricing] of Object.entries(overrides)) {
63
+ const k = key.toLowerCase();
64
+ if (m.startsWith(k) && (!best || k.length >= best.key.length)) {
65
+ best = { key: k, pricing };
66
+ }
67
+ }
68
+ return best ? best.pricing : DEFAULT_MODEL_PRICING;
69
+ }