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/README.md
CHANGED
|
@@ -50,14 +50,17 @@ npx app-scoder-agent "your task"
|
|
|
50
50
|
## Quick Start
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
#
|
|
53
|
+
# One-time setup wizard — writes .scoderrc.json (base URL, key, model, build cmd)
|
|
54
|
+
scoder init
|
|
55
|
+
|
|
56
|
+
# …or set your API key manually
|
|
54
57
|
export SCODER_API_KEY=sk-... # any OpenAI-compatible key (OpenAI, Groq, OpenRouter, Ollama, …)
|
|
55
58
|
export OPENAI_BASE_URL=https://api.openai.com/v1 # optional, for custom endpoints
|
|
56
59
|
|
|
57
60
|
# Run a task
|
|
58
61
|
scoder "add a ping function to src/network.ts"
|
|
59
62
|
|
|
60
|
-
# Interactive REPL
|
|
63
|
+
# Interactive REPL (supports /help, /clear, /cost, /model)
|
|
61
64
|
scoder
|
|
62
65
|
```
|
|
63
66
|
|
|
@@ -80,9 +83,22 @@ You (task) → LLM plans → Tools execute code → Build runs → Success? →
|
|
|
80
83
|
| `edit_file` | Find-and-replace (unique match required) |
|
|
81
84
|
| `list_files` | Recursive directory listing |
|
|
82
85
|
| `run_command` | Execute any shell command (subject to permission policy) |
|
|
86
|
+
| `web_fetch` | Fetch a public HTTP(S) URL → readable text (HTML→text, JSON pretty-printed) |
|
|
83
87
|
|
|
84
88
|
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
89
|
|
|
90
|
+
### Web access (`web_fetch`)
|
|
91
|
+
|
|
92
|
+
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).
|
|
93
|
+
|
|
94
|
+
Safety defaults:
|
|
95
|
+
|
|
96
|
+
- **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.
|
|
97
|
+
- Only `http`/`https`; up to 5 redirects — every hop is re-validated against the private-address block list.
|
|
98
|
+
- 30 s timeout, SIGINT-abortable; body capped at 512 KB; extracted text capped at 20k chars (configurable via `max_chars`).
|
|
99
|
+
- HTML is converted to plain text (no JS execution); images/archives/PDFs are refused.
|
|
100
|
+
- Read-only — the tool never writes locally, so it is allowed even in `readonly` permission mode.
|
|
101
|
+
|
|
86
102
|
## Configuration
|
|
87
103
|
|
|
88
104
|
### Environment variables
|
|
@@ -96,6 +112,9 @@ File writes (`write_file` / `edit_file`) are symlink-safe: paths are resolved vi
|
|
|
96
112
|
| `SCODER_MAX_STEPS` | `40` | Max loop iterations before giving up |
|
|
97
113
|
| `SCODER_PERMISSION` | `auto` | Permission mode: `auto` \| `ask` \| `readonly` |
|
|
98
114
|
| `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
|
|
115
|
+
| `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
|
|
116
|
+
| `SCODER_SERVER_TOKEN` | — | `scoder serve` only: require `Authorization: Bearer <token>` |
|
|
117
|
+
| `SCODER_WEB_ALLOW_PRIVATE` | — | Set to `1` to let `web_fetch` reach localhost/private addresses (local dev) |
|
|
99
118
|
| `SCODER_NO_APIKEY_WARNING` | — | Set to `1` to suppress the apiKey-in-rc warning |
|
|
100
119
|
|
|
101
120
|
### Per-project config file (.scoderrc.json)
|
|
@@ -225,7 +244,61 @@ Sessions older than 7 days are pruned automatically on the next run. Use `--no-s
|
|
|
225
244
|
|
|
226
245
|
## Streaming
|
|
227
246
|
|
|
228
|
-
|
|
247
|
+
scoder streams everything it can, live, as it happens:
|
|
248
|
+
|
|
249
|
+
- **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`.
|
|
250
|
+
- **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.
|
|
251
|
+
- **SIGINT-safe** — Ctrl+C kills the running command immediately; streamed rendering never blocks the event loop.
|
|
252
|
+
- Output honors `NO_COLOR` and degrades to plain text when piped (CI-friendly).
|
|
253
|
+
|
|
254
|
+
## Full-Screen TUI (`scoder tui`)
|
|
255
|
+
|
|
256
|
+
**Mission Control for the build loop** — a terminal UI built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals), driven by the same `AgentEvent` stream the HTTP API exposes:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
scoder tui # start an interactive full-screen session
|
|
260
|
+
scoder tui "fix the bug" # auto-submit an initial task
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Features:
|
|
264
|
+
|
|
265
|
+
- **Build Rail** (top bar) — brand, build lamp (`● build ok` / `● build fail` / `no build yet`), permission mode, and a step fuel gauge (`▰▰▰▱▱▱▱▱▱▱`) that fills as the agent burns through its step budget
|
|
266
|
+
- **Chat pane** — your messages, streaming assistant text, and every tool call with live stdout/stderr preview (`✔/✘` status, spinner while running), prefixed with a flight-recorder step number (`03▸`)
|
|
267
|
+
- **Diff cards** (`✎`) — every file write/edit renders as a unified diff (`+` green / `-` red / `@@` cyan hunk headers); the same diff also flows out of the HTTP SSE stream
|
|
268
|
+
- **Status bar** — model, token usage (input/output/cached), keybind hints
|
|
269
|
+
- **Slash commands** — `/help`, `/clear`, `/cost`, `/model`, `/exit` (identical to the REPL)
|
|
270
|
+
- **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
|
|
271
|
+
- **Scrollback** — `PgUp`/`PgDn` page through history; `Esc` returns to the live tail (press again to abort)
|
|
272
|
+
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
|
|
273
|
+
- Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
|
|
274
|
+
- `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
|
|
275
|
+
|
|
276
|
+
## HTTP Server Mode (`scoder serve`)
|
|
277
|
+
|
|
278
|
+
Run scoder as a local HTTP service — submit tasks programmatically and consume progress as Server-Sent Events:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
scoder serve # http://127.0.0.1:0 (random port, printed at startup)
|
|
282
|
+
scoder serve --port 4114 # fixed port
|
|
283
|
+
SCODER_SERVER_TOKEN=secret scoder serve --host 127.0.0.1
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| Endpoint | Method | Description |
|
|
287
|
+
|----------|--------|-------------|
|
|
288
|
+
| `/api/health` | GET | Liveness: `{ ok, running, runs }` |
|
|
289
|
+
| `/api/runs` | POST | Start a run: `{ "task": "...", "workdir?", "buildCmd?", "model?", "provider?", "maxSteps?", "permission?" }` → `202 { id, eventsUrl }` |
|
|
290
|
+
| `/api/runs` | GET | List runs with status/timing |
|
|
291
|
+
| `/api/runs/<id>` | GET | Run status + final result |
|
|
292
|
+
| `/api/runs/<id>/events` | GET | **SSE stream** — full event replay, then live, closes on finish |
|
|
293
|
+
| `/api/runs/<id>/abort` | POST | Abort a running run (equivalent to SIGINT) |
|
|
294
|
+
|
|
295
|
+
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.
|
|
296
|
+
|
|
297
|
+
Notes:
|
|
298
|
+
|
|
299
|
+
- Runs are always non-interactive; `permission: "ask"` is rejected — use `auto` or `readonly`.
|
|
300
|
+
- Binds `127.0.0.1` by default. Set `SCODER_SERVER_TOKEN` to require `Authorization: Bearer <token>` on every request.
|
|
301
|
+
- Max 4 concurrent runs (extra requests get `429`); per-run overrides: `workdir`, `buildCmd`, `model`, `provider`, `maxSteps`, `permission`.
|
|
229
302
|
|
|
230
303
|
## Logging
|
|
231
304
|
|
|
@@ -268,6 +341,9 @@ scoder detects the build command automatically:
|
|
|
268
341
|
```
|
|
269
342
|
scoder [options] "<task>" Run a task autonomously until the build passes
|
|
270
343
|
scoder Start interactive session
|
|
344
|
+
scoder init Setup wizard — writes .scoderrc.json (+ connection test)
|
|
345
|
+
scoder tui [task] Full-screen terminal UI (Mission Control)
|
|
346
|
+
scoder serve [--port] [--host] HTTP server mode (see above)
|
|
271
347
|
scoder --help | --version Help and version
|
|
272
348
|
```
|
|
273
349
|
|
|
@@ -283,6 +359,9 @@ scoder --help | --version Help and version
|
|
|
283
359
|
| `-y` | Non-interactive mode (denies commands needing confirmation) |
|
|
284
360
|
| `--provider <name>` | `openai` \| `anthropic` (env SCODER_PROVIDER; auto-detected) |
|
|
285
361
|
| `--no-save` | Disable session auto-save to `.scoder/sessions/` |
|
|
362
|
+
| `--log-file` | Write a structured JSONL event log to `.scoder/logs/` |
|
|
363
|
+
| `--port <n>` | (`serve`) TCP port, `0` = random free port |
|
|
364
|
+
| `--host <addr>` | (`serve`) Bind address (default `127.0.0.1`) |
|
|
286
365
|
|
|
287
366
|
## Examples
|
|
288
367
|
|
|
@@ -305,6 +384,15 @@ scoder -m claude-sonnet-4-5 "refactor the parser module"
|
|
|
305
384
|
|
|
306
385
|
# CI: non-interactive, no session saving
|
|
307
386
|
scoder --no-save -y "run the full migration"
|
|
387
|
+
|
|
388
|
+
# Run as an HTTP service and consume progress via SSE
|
|
389
|
+
scoder serve --port 4114 &
|
|
390
|
+
curl -s -X POST localhost:4114/api/runs -H 'Content-Type: application/json' \
|
|
391
|
+
-d '{"task": "add input validation to the CLI"}'
|
|
392
|
+
curl -N http://127.0.0.1:4114/api/runs/<id>/events # live progress
|
|
393
|
+
|
|
394
|
+
# Fetch external docs during a task (agent decides when, via web_fetch)
|
|
395
|
+
scoder "upgrade express to v5 — check the official migration guide first"
|
|
308
396
|
```
|
|
309
397
|
|
|
310
398
|
## Architecture
|
|
@@ -312,32 +400,40 @@ scoder --no-save -y "run the full migration"
|
|
|
312
400
|
```
|
|
313
401
|
┌─────────────────────────────────────────────┐
|
|
314
402
|
│ CLI (src/index.ts) │
|
|
315
|
-
│ arg parsing, REPL, SIGINT
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
│ Agent (src/agent.ts)
|
|
320
|
-
│
|
|
321
|
-
│
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
│
|
|
326
|
-
│
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
403
|
+
│ arg parsing, REPL, serve, tui, SIGINT │
|
|
404
|
+
└───────┬─────────────────────┬───────────────┘
|
|
405
|
+
│ │
|
|
406
|
+
┌───────▼───────────┐ ┌──────▼─────────────────────┐
|
|
407
|
+
│ TUI (src/tui/) │ │ Agent (src/agent.ts) │
|
|
408
|
+
│ Ink/React full- │ │ Closed-loop orchestrator │
|
|
409
|
+
│ screen chat UI, │─▶│ history trimming, error │
|
|
410
|
+
│ renders the same │ │ injection │
|
|
411
|
+
│ AgentEvent stream│ │ emits AgentEvent │
|
|
412
|
+
└───────────────────┘ └──┬──────────┬──────────────┘
|
|
413
|
+
│ │
|
|
414
|
+
│ └────────────────────┐
|
|
415
|
+
▼ ▼
|
|
416
|
+
┌───────────────────────────────┐ ┌──────────────────────────┐
|
|
417
|
+
│ LLM Client (src/llm.ts) │ │ Server (src/server.ts) │
|
|
418
|
+
│ OpenAI-compatible + native │ │ HTTP API + SSE │
|
|
419
|
+
│ Anthropic provider │ │ runs in background │
|
|
420
|
+
└──────────────┬────────────────┘ └──────────────────────────┘
|
|
421
|
+
▼
|
|
422
|
+
┌────────────────────────────────────────┐
|
|
423
|
+
│ Tool Registry (src/tools.ts) │
|
|
424
|
+
│ + web_fetch (src/webfetch.ts) │
|
|
425
|
+
└──────────────┬─────────────────────────┘
|
|
426
|
+
▼
|
|
427
|
+
┌───────────────────────────────┐
|
|
428
|
+
│ Build Checker (src/build.ts) │
|
|
429
|
+
│ run build → exit 0 = stop │
|
|
430
|
+
└───────────────────────────────┘
|
|
335
431
|
```
|
|
336
432
|
|
|
337
433
|
## Integration Testing
|
|
338
434
|
|
|
339
435
|
```bash
|
|
340
|
-
npm test # runs:
|
|
436
|
+
npm test # runs: build → unit tests (node --test) → e2e closed-loop → MCP smoke
|
|
341
437
|
npm run lint # eslint flat config (typescript-eslint + eqeqeq, prefer-const...)
|
|
342
438
|
npm run typecheck # tsc --noEmit
|
|
343
439
|
```
|
|
@@ -345,17 +441,20 @@ npm run typecheck # tsc --noEmit
|
|
|
345
441
|
Test suites (run with `npm test` — auto-discovers `test/*.test.mjs`):
|
|
346
442
|
- `test/permissions.test.mjs` — catastrophic patterns, mode classification, allowlist word-boundary, timeout-denies
|
|
347
443
|
- `test/permission-prompt.test.mjs` — interactive prompt timeout behavior
|
|
348
|
-
- `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list
|
|
444
|
+
- `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list, live `run_command` output streaming
|
|
445
|
+
- `test/webfetch.test.mjs` — HTML→text, JSON, redirects, SSRF guard, binary refusal, truncation
|
|
446
|
+
- `test/server.test.mjs` — HTTP API: run lifecycle via SSE, abort, auth, live `tool_output` events
|
|
447
|
+
- `test/tui.test.mjs` — full-screen TUI in a real PTY (via `test/pty-run.py`): closed loop render + slash commands (`/help`, `/cost`) + clean exit
|
|
448
|
+
- `test/tui-state.test.mjs` — event reducer unit tests: diff items, build lamp, abort marking, slash-command parsing & help formatting
|
|
349
449
|
- `test/core.test.mjs` — unified diff, retry backoff, context trimming
|
|
350
450
|
- `test/config.test.mjs` — env/rc/cli precedence, logFile mapping, permission lists
|
|
351
451
|
- `test/session.test.mjs` — session store, smart prune, resolve
|
|
452
|
+
- `test/cost.test.mjs` — token usage & cost aggregation
|
|
352
453
|
- `test/anthropic.test.mjs` — message translation + native SSE streaming
|
|
353
|
-
- `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction
|
|
454
|
+
- `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction + wire-format normalization
|
|
354
455
|
- `test/run-e2e.mjs` — closed-loop integration (mock LLM server)
|
|
355
456
|
- `test/run-mcp-test.mjs` — MCP smoke (mock stdio server)
|
|
356
457
|
|
|
357
|
-
Coverage (lines): **90.3%** overall — see `npm run coverage`.
|
|
358
|
-
|
|
359
458
|
## Exit Codes
|
|
360
459
|
|
|
361
460
|
| 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;
|
|
@@ -48,6 +54,10 @@ export class Agent {
|
|
|
48
54
|
mcp = null;
|
|
49
55
|
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
50
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;
|
|
51
61
|
constructor(config) {
|
|
52
62
|
this.config = config;
|
|
53
63
|
const commonOpts = {
|
|
@@ -72,6 +82,7 @@ export class Agent {
|
|
|
72
82
|
workdir: config.workdir,
|
|
73
83
|
permissions: this.permissions,
|
|
74
84
|
permissionMode: config.permissionMode,
|
|
85
|
+
onToolOutput: this.handleToolOutput,
|
|
75
86
|
};
|
|
76
87
|
this.session = new SessionStore(config.workdir, config.noSave !== true);
|
|
77
88
|
this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
|
|
@@ -92,6 +103,27 @@ export class Agent {
|
|
|
92
103
|
// terminate its child process immediately (long builds must be interruptible).
|
|
93
104
|
this.ctx.signal = this.runSignal.signal;
|
|
94
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
|
+
}
|
|
95
127
|
/** Push one tool-result message back into the conversation. */
|
|
96
128
|
pushToolResult(tc, content) {
|
|
97
129
|
this.messages.push({
|
|
@@ -108,6 +140,7 @@ export class Agent {
|
|
|
108
140
|
*/
|
|
109
141
|
rejectToolCall(step, tc, output, logExtra) {
|
|
110
142
|
ui.toolResult(tc.name, false, output);
|
|
143
|
+
this.emit({ type: "tool_result", step, name: tc.name, ok: false, output });
|
|
111
144
|
this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
|
|
112
145
|
this.pushToolResult(tc, output);
|
|
113
146
|
}
|
|
@@ -115,6 +148,10 @@ export class Agent {
|
|
|
115
148
|
* Record usage of one LLM call: render token counts, log an `llm_usage`
|
|
116
149
|
* event, and accumulate totals into session state.
|
|
117
150
|
*/
|
|
151
|
+
/** Token usage totals accumulated in this session (for REPL `/cost`). */
|
|
152
|
+
getUsage() {
|
|
153
|
+
return { ...this.usageTotals };
|
|
154
|
+
}
|
|
118
155
|
recordLlmUsage(usage) {
|
|
119
156
|
if (!usage)
|
|
120
157
|
return;
|
|
@@ -126,6 +163,10 @@ export class Agent {
|
|
|
126
163
|
this.usageTotals.outputTokens += output;
|
|
127
164
|
this.usageTotals.cachedTokens += cached;
|
|
128
165
|
ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
|
|
166
|
+
this.emit({
|
|
167
|
+
type: "llm_usage",
|
|
168
|
+
usage: { input_tokens: input, output_tokens: output, cached_tokens: cached },
|
|
169
|
+
});
|
|
129
170
|
this.logger.log({
|
|
130
171
|
type: "llm_usage",
|
|
131
172
|
model: this.client.model,
|
|
@@ -161,6 +202,7 @@ export class Agent {
|
|
|
161
202
|
started = true;
|
|
162
203
|
}
|
|
163
204
|
ui.llmStreamChunk(chunk.contentDelta);
|
|
205
|
+
this.emit({ type: "llm_delta", delta: chunk.contentDelta });
|
|
164
206
|
streamedText += chunk.contentDelta;
|
|
165
207
|
}
|
|
166
208
|
if (chunk.final)
|
|
@@ -217,9 +259,13 @@ export class Agent {
|
|
|
217
259
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
218
260
|
}
|
|
219
261
|
const buildCmd = this.buildCmd;
|
|
220
|
-
ui.
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
ui.banner("scoder", [
|
|
263
|
+
["task", task],
|
|
264
|
+
["model", `${this.client.model} (${this.config.provider}${this.config.stream ? ", stream" : ""})`],
|
|
265
|
+
["workdir", this.config.workdir],
|
|
266
|
+
["build", buildCmd],
|
|
267
|
+
["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
|
|
268
|
+
]);
|
|
223
269
|
this.currentTask = task;
|
|
224
270
|
this.resetRunSignal();
|
|
225
271
|
this.resetUsageTotals();
|
|
@@ -231,6 +277,14 @@ export class Agent {
|
|
|
231
277
|
},
|
|
232
278
|
{ role: "user", content: task },
|
|
233
279
|
];
|
|
280
|
+
this.emit({
|
|
281
|
+
type: "run_start",
|
|
282
|
+
task,
|
|
283
|
+
model: this.client.model,
|
|
284
|
+
provider: this.config.provider,
|
|
285
|
+
workdir: this.config.workdir,
|
|
286
|
+
buildCmd,
|
|
287
|
+
});
|
|
234
288
|
try {
|
|
235
289
|
await this.connectMcp();
|
|
236
290
|
return await this.runLoop(0, buildCmd, null);
|
|
@@ -304,6 +358,7 @@ export class Agent {
|
|
|
304
358
|
while (steps < this.config.maxSteps && !this.aborted) {
|
|
305
359
|
steps++;
|
|
306
360
|
ui.step(steps, this.config.maxSteps, "thinking...");
|
|
361
|
+
this.emit({ type: "step", step: steps, maxSteps: this.config.maxSteps });
|
|
307
362
|
const { message, usage } = await this.chatWithStream().catch((err) => {
|
|
308
363
|
// SIGINT during the LLM call — stop gracefully instead of crashing
|
|
309
364
|
if (this.aborted) {
|
|
@@ -363,6 +418,8 @@ export class Agent {
|
|
|
363
418
|
continue;
|
|
364
419
|
}
|
|
365
420
|
ui.toolCall(tc.name, args);
|
|
421
|
+
this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
|
|
422
|
+
this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
|
|
366
423
|
if (!tool && !(this.mcp?.hasTool(tc.name))) {
|
|
367
424
|
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" });
|
|
368
425
|
continue;
|
|
@@ -426,7 +483,17 @@ export class Agent {
|
|
|
426
483
|
this.aborted = true;
|
|
427
484
|
ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
|
|
428
485
|
}
|
|
486
|
+
this.endToolStream();
|
|
487
|
+
this.currentToolCall = null;
|
|
429
488
|
ui.toolResult(tc.name, result.ok, result.output);
|
|
489
|
+
this.emit({
|
|
490
|
+
type: "tool_result",
|
|
491
|
+
step: steps,
|
|
492
|
+
name: tc.name,
|
|
493
|
+
ok: result.ok,
|
|
494
|
+
output: result.output,
|
|
495
|
+
...(result.diff ? { diff: result.diff } : {}),
|
|
496
|
+
});
|
|
430
497
|
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
431
498
|
this.pushToolResult(tc, summariseOutput(result.output));
|
|
432
499
|
}
|
|
@@ -483,8 +550,10 @@ export class Agent {
|
|
|
483
550
|
}
|
|
484
551
|
/** Explicitly verify the build (independent of what the LLM claims). */
|
|
485
552
|
async checkBuild(buildCmd) {
|
|
486
|
-
const res = await runBuild(this.config.workdir, buildCmd);
|
|
553
|
+
const res = await runBuild(this.config.workdir, buildCmd, (stream, delta) => this.handleToolOutput("build", stream, delta));
|
|
554
|
+
this.endToolStream();
|
|
487
555
|
ui.buildStatus(res.success, res.output);
|
|
556
|
+
this.emit({ type: "build", success: res.success, command: buildCmd, output: res.output });
|
|
488
557
|
this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
|
|
489
558
|
return res;
|
|
490
559
|
}
|
|
@@ -495,6 +564,7 @@ export class Agent {
|
|
|
495
564
|
else {
|
|
496
565
|
ui.error(`Task NOT complete. ${summary}`);
|
|
497
566
|
}
|
|
567
|
+
this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000) });
|
|
498
568
|
this.logger.log({ type: "finish", success, steps });
|
|
499
569
|
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
500
570
|
}
|
package/dist/build.js
CHANGED
|
@@ -30,9 +30,10 @@ export function detectBuildCommand(workdir) {
|
|
|
30
30
|
/**
|
|
31
31
|
* Run the build command. This is the agent's stop condition:
|
|
32
32
|
* exit code 0 == task complete.
|
|
33
|
+
* `onOutput` (optional) receives stdout/stderr chunks live while the build runs.
|
|
33
34
|
*/
|
|
34
|
-
export async function runBuild(workdir, command) {
|
|
35
|
-
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);
|
|
36
37
|
return {
|
|
37
38
|
success: res.ok,
|
|
38
39
|
command,
|