s-app-scoder-agent 0.6.0 → 0.8.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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +600 -468
  4. package/dist/agent.js +841 -197
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +423 -0
  11. package/dist/index.js +129 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +103 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +32 -2
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +505 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +146 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +99 -0
  44. package/dist/tui/state.js +192 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. package/dist/tui-direct.mjs +0 -4
package/dist/index.js CHANGED
@@ -8,56 +8,65 @@ 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
+ import { dispatchCommand } from "./tui/command-registry.js";
12
12
  import * as ui from "./ui.js";
13
13
  // Single source of truth: read version from package.json (see src/version.ts).
14
14
  const VERSION = getPackageVersion();
15
- const HELP = `scoder — autonomous coding agent CLI
16
-
17
- USAGE
18
- scoder [options] "<task>" Run a task autonomously until the build passes
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
22
- scoder list List saved sessions for this directory
23
- scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
24
- scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
25
- scoder cost [--session <id>] [--last <n>]
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)
30
- scoder --version | --help
31
-
32
- OPTIONS
33
- -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
34
- --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
35
- -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
36
- --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
37
- -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
38
- -d, --dir <path> Working directory (default: current directory)
39
- -s, --max-steps <n> Max autonomous loop iterations (default 40)
40
- -y, --yes Non-interactive mode (auto-approves commands)
41
- --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
42
- --no-save Disable session auto-save to .scoder/sessions/
43
- --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
44
- (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
45
- --session <id> (cost) Aggregate usage of one specific session
46
- --last <n> (cost) Aggregate usage of the n most recent sessions
47
- -h, --help Show this help
48
- -v, --version Show version
49
-
50
- CONFIG FILE
51
- .scoderrc.json in the working directory (see .scoderrc.example.json):
52
- { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
53
- "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
54
-
55
- EXAMPLES
56
- scoder "add a divide function to src/math.ts with tests"
57
- scoder --build "make test" "fix the failing test in utils"
58
- scoder --permission ask "npm install express and wire up a route"
59
- scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
60
- scoder resume last "now add unit tests"
15
+ // Safety net: a stray promise rejection (e.g. a provider socket aborting after
16
+ // its consumer already moved on) must log — never hard-crash the agent.
17
+ process.on("unhandledRejection", (reason) => {
18
+ const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
19
+ ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
20
+ });
21
+ const HELP = `scoder — autonomous coding agent CLI
22
+
23
+ USAGE
24
+ scoder [options] "<task>" Run a task autonomously until the build passes
25
+ scoder Start interactive session (chat + tools)
26
+ scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
27
+ scoder init First-run wizard: writes .scoderrc.json + tests the connection
28
+ scoder list List saved sessions for this directory
29
+ scoder models [provider] [--refresh]
30
+ Model catalog (context window, pricing) —
31
+ synced from models.dev, cached 24h
32
+ scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
33
+ scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
34
+ scoder cost [--session <id>] [--last <n>]
35
+ Token usage & estimated cost from JSONL logs
36
+ scoder serve [--port <n>] [--host <addr>]
37
+ Run as an HTTP server: POST tasks, stream
38
+ progress via Server-Sent Events (see below)
39
+ scoder --version | --help
40
+
41
+ OPTIONS
42
+ -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
43
+ --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
44
+ -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
45
+ --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
46
+ -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
47
+ -d, --dir <path> Working directory (default: current directory)
48
+ -s, --max-steps <n> Max autonomous loop iterations (default 40)
49
+ -y, --yes Non-interactive mode (auto-approves commands)
50
+ --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
51
+ --no-save Disable session auto-save to .scoder/sessions/
52
+ --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
53
+ (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
54
+ --session <id> (cost) Aggregate usage of one specific session
55
+ --last <n> (cost) Aggregate usage of the n most recent sessions
56
+ -h, --help Show this help
57
+ -v, --version Show version
58
+
59
+ CONFIG FILE
60
+ .scoderrc.json in the working directory (see .scoderrc.example.json):
61
+ { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
62
+ "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
63
+
64
+ EXAMPLES
65
+ scoder "add a divide function to src/math.ts with tests"
66
+ scoder --build "make test" "fix the failing test in utils"
67
+ scoder --permission ask "npm install express and wire up a route"
68
+ scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
69
+ scoder resume last "now add unit tests"
61
70
  `;
62
71
  function parseArgs(argv) {
63
72
  const args = { task: null, yes: false, help: false, version: false };
@@ -181,6 +190,13 @@ function parseArgs(argv) {
181
190
  args.subcommand = "tui";
182
191
  args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
183
192
  }
193
+ else if (rest[0] === "models") {
194
+ // scoder models [provider] [--refresh] — models.dev catalog (I-02)
195
+ args.subcommand = "models";
196
+ args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
197
+ args.modelsRefresh = rest.includes("--refresh");
198
+ args.task = null;
199
+ }
184
200
  else if (rest[0] === "init") {
185
201
  args.subcommand = "init";
186
202
  args.task = null;
@@ -204,6 +220,9 @@ async function runOnce(cfg, task) {
204
220
  ui.error(err.message);
205
221
  return 2;
206
222
  }
223
+ finally {
224
+ agent.dispose();
225
+ }
207
226
  }
208
227
  /**
209
228
  * `scoder init` — first-run wizard: asks for the essentials, writes
@@ -227,9 +246,19 @@ export async function runInit() {
227
246
  }
228
247
  const rl = createInterface({ input: process.stdin, output: process.stdout });
229
248
  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");
249
+ // I-03: probe local engines first — suggest when found (no auto-switch when
250
+ // a cloud key exists; user always decides).
251
+ ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
252
+ const { discoverLocalEngines } = await import("./provider-registry.js");
253
+ const locals = await discoverLocalEngines();
254
+ for (const e of locals) {
255
+ ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
256
+ }
257
+ const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
258
+ const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
259
+ const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
231
260
  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");
261
+ const model = await ask("Model", suggestModel);
233
262
  const buildCmd = await ask("Build command (empty = auto-detect)", "");
234
263
  const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
235
264
  rl.close();
@@ -290,35 +319,37 @@ async function repl(cfg) {
290
319
  continue;
291
320
  if (input === "exit" || input === "quit")
292
321
  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") {
322
+ // Slash commands — registry dùng chung với TUI (T1). Command riêng của
323
+ // TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
324
+ const host = {
325
+ agent: () => agent,
326
+ config: cfg,
327
+ replaceAgent: (next) => {
303
328
  agent.abort();
304
- agent = new Agent(cfg);
329
+ agent.dispose();
330
+ agent = next ?? new Agent(cfg);
305
331
  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`);
332
+ },
333
+ resumeAndRun: async (s) => {
334
+ agent.abort();
335
+ agent.dispose();
336
+ agent = new Agent(cfg);
337
+ first = false;
338
+ await agent.resume(s);
339
+ return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
340
+ },
341
+ };
342
+ const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
343
+ if (level === "error")
344
+ ui.error(text);
345
+ else
346
+ console.log(text);
347
+ });
348
+ if (handled) {
349
+ if (outcome?.quit)
350
+ break;
351
+ if (outcome?.notice)
352
+ ui.info(outcome.notice);
322
353
  continue;
323
354
  }
324
355
  try {
@@ -331,6 +362,7 @@ async function repl(cfg) {
331
362
  ui.error(err.message);
332
363
  }
333
364
  }
365
+ agent.dispose();
334
366
  ui.info("bye");
335
367
  process.exit(0);
336
368
  }
@@ -393,9 +425,11 @@ async function main() {
393
425
  });
394
426
  try {
395
427
  const result = await agent.resume(s, args.task ?? undefined);
428
+ agent.dispose();
396
429
  process.exit(result.success ? 0 : 1);
397
430
  }
398
431
  catch (err) {
432
+ agent.dispose();
399
433
  ui.error(err.message);
400
434
  process.exit(2);
401
435
  }
@@ -433,6 +467,25 @@ async function main() {
433
467
  console.log(formatCostReport(sessionCosts));
434
468
  return;
435
469
  }
470
+ if (args.subcommand === "models") {
471
+ const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
472
+ const filter = args.modelsProvider ?? null;
473
+ const { models, source } = await loadCatalog(args.modelsRefresh === true);
474
+ const age = cacheAgeHours();
475
+ const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
476
+ console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
477
+ const rows = Object.values(models)
478
+ .filter((m) => !filter || m.provider === filter || m.id.includes(filter))
479
+ .sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
480
+ .slice(0, 60);
481
+ for (const m of rows) {
482
+ const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
483
+ console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
484
+ }
485
+ if (rows.length === 0)
486
+ console.log(` (no models match "${filter}")`);
487
+ return;
488
+ }
436
489
  if (args.subcommand === "tui") {
437
490
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
438
491
  ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
@@ -0,0 +1,58 @@
1
+ /**
2
+ * M2: JSON-Schema subset validator — không dependency. Hỗ trợ: type,
3
+ * required, properties, enum, items, additionalProperties=false khi khai báo.
4
+ * Trả về mảng lỗi (rỗng = hợp lệ). Dùng để validate MCP tool args trước
5
+ * tools/call — lỗi local, không gọi server.
6
+ */
7
+ const TYPE_CHECKS = {
8
+ string: (v) => typeof v === "string",
9
+ number: (v) => typeof v === "number" && Number.isFinite(v),
10
+ integer: (v) => typeof v === "number" && Number.isInteger(v),
11
+ boolean: (v) => typeof v === "boolean",
12
+ object: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
13
+ array: (v) => Array.isArray(v),
14
+ null: (v) => v === null,
15
+ };
16
+ /** Validate value theo schema subset. path dùng cho error message. */
17
+ export function validateSchema(value, schema, path = "args") {
18
+ if (!schema || typeof schema !== "object")
19
+ return [];
20
+ const errors = [];
21
+ const type = schema.type;
22
+ if (type !== undefined) {
23
+ const types = Array.isArray(type) ? type : [type];
24
+ const ok = types.some((t) => TYPE_CHECKS[t]?.(value));
25
+ if (!ok) {
26
+ errors.push(`${path}: expected ${types.join("|")}, got ${jsonType(value)}`);
27
+ return errors; // sai type — không kiểm tra sâu hơn
28
+ }
29
+ }
30
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
31
+ errors.push(`${path}: must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
32
+ }
33
+ if (TYPE_CHECKS.object(value)) {
34
+ const obj = value;
35
+ const props = (schema.properties ?? {});
36
+ for (const req of schema.required ?? []) {
37
+ if (!(req in obj))
38
+ errors.push(`${path}: missing required property "${req}"`);
39
+ }
40
+ for (const [k, v] of Object.entries(obj)) {
41
+ if (props[k])
42
+ errors.push(...validateSchema(v, props[k], `${path}.${k}`));
43
+ else if (schema.additionalProperties === false)
44
+ errors.push(`${path}: unexpected property "${k}"`);
45
+ }
46
+ }
47
+ if (Array.isArray(value) && schema.items) {
48
+ value.forEach((item, i) => errors.push(...validateSchema(item, schema.items, `${path}[${i}]`)));
49
+ }
50
+ return errors;
51
+ }
52
+ function jsonType(v) {
53
+ if (v === null)
54
+ return "null";
55
+ if (Array.isArray(v))
56
+ return "array";
57
+ return typeof v;
58
+ }
package/dist/llm.js CHANGED
@@ -3,6 +3,29 @@
3
3
  * and optional SSE streaming.
4
4
  */
5
5
  import { withRetry } from "./retry.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Secrets redaction (H-13) — shared by LLM error bodies, MCP tool output,
8
+ // compaction summaries and snapshot logs. Never log raw credentials.
9
+ // ---------------------------------------------------------------------------
10
+ const REDACTION_PATTERNS = [
11
+ [/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***"],
12
+ [/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***"],
13
+ [/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***"],
14
+ [/ghp_[A-Za-z0-9]{20,}/g, "ghp_***REDACTED***"],
15
+ [/github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***REDACTED***"],
16
+ [/xox[bap]-[A-Za-z0-9-]{10,}/g, "xox***REDACTED***"],
17
+ [/AKIA[0-9A-Z]{16}/g, "AKIA***REDACTED***"],
18
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "***PRIVATE-KEY***"],
19
+ [/\b(api[_-]?key|token|secret|password)["'\s:=]+[^\s"'&]{8,}/gi, "$1=***REDACTED***"],
20
+ ];
21
+ /** Redact known secret shapes from arbitrary tool/LLM/log output. */
22
+ export function redactSecrets(text) {
23
+ let out = text;
24
+ for (const [rx, replacement] of REDACTION_PATTERNS) {
25
+ out = out.replace(rx, replacement);
26
+ }
27
+ return out;
28
+ }
6
29
  export class LlmError extends Error {
7
30
  status;
8
31
  retryAfterMs;
@@ -19,8 +42,7 @@ export class LlmError extends Error {
19
42
  async function toLlmError(res, url) {
20
43
  let body = await res.text().catch(() => "");
21
44
  // Redact Bearer token and raw key patterns to avoid leaking secrets in logs/errors
22
- body = body.replace(/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***");
23
- body = body.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***");
45
+ body = redactSecrets(body);
24
46
  const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
25
47
  // Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
26
48
  const retryAfterHeader = res.headers.get("retry-after");
@@ -53,6 +75,8 @@ export function combinedSignal(external, timeoutMs) {
53
75
  }
54
76
  return {
55
77
  signal: ac.signal,
78
+ /** Clear only the request timer — keep the SIGINT listener alive. */
79
+ stopTimer: () => clearTimeout(timer),
56
80
  cleanup: () => {
57
81
  clearTimeout(timer);
58
82
  external?.removeEventListener("abort", onExternal);
@@ -102,6 +126,8 @@ function toWireMessages(messages) {
102
126
  export class LlmClient {
103
127
  opts;
104
128
  streamEnabled;
129
+ /** Last interrupted stream's accumulated tool calls (H-09 continuation). */
130
+ lastPartialToolCalls = [];
105
131
  constructor(opts) {
106
132
  this.opts = opts;
107
133
  this.streamEnabled = opts.stream !== false; // default true
@@ -109,10 +135,17 @@ export class LlmClient {
109
135
  get model() {
110
136
  return this.opts.model;
111
137
  }
138
+ /** T-04: runtime hot-swap — next request uses the new model id. */
139
+ setModel(model) {
140
+ this.opts = { ...this.opts, model };
141
+ }
142
+ get timeoutMs() {
143
+ return this.opts.requestTimeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
144
+ }
112
145
  // ── Non-streaming ──────────────────────────────────────────────────────────
113
146
  async chat(messages, tools, opts = {}) {
114
147
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
115
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
148
+ const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
116
149
  const body = JSON.stringify({
117
150
  model: this.opts.model,
118
151
  messages: toWireMessages(messages),
@@ -135,10 +168,21 @@ export class LlmClient {
135
168
  return r;
136
169
  }, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
137
170
  }
171
+ catch (err) {
172
+ cleanup(); // timer must not survive a failed request
173
+ throw err;
174
+ }
175
+ let data;
176
+ try {
177
+ // Keep the timeout alive through the body read — a hung body must also
178
+ // surface as a timeout, not an eternal await.
179
+ const pending = res.json();
180
+ pending.catch(() => { }); // no-op catch for the abort path; awaited below
181
+ data = (await pending);
182
+ }
138
183
  finally {
139
184
  cleanup();
140
185
  }
141
- const data = (await res.json());
142
186
  const choice = data.choices?.[0]?.message;
143
187
  if (!choice)
144
188
  throw new LlmError("LLM API returned no message in response");
@@ -157,6 +201,7 @@ export class LlmClient {
157
201
  role: "assistant",
158
202
  content: choice.content ?? null,
159
203
  stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
204
+ reasoning: typeof choice.reasoning_content === "string" && choice.reasoning_content ? choice.reasoning_content : undefined,
160
205
  tool_calls: choice.tool_calls?.map((tc) => ({
161
206
  id: tc.id ?? "",
162
207
  name: tc.function?.name ?? "",
@@ -173,7 +218,7 @@ export class LlmClient {
173
218
  */
174
219
  async *streamChat(messages, tools, opts = {}) {
175
220
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
176
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
221
+ const { signal, cleanup, stopTimer } = combinedSignal(opts.signal, this.timeoutMs);
177
222
  const bodyStr = JSON.stringify({
178
223
  model: this.opts.model,
179
224
  messages: toWireMessages(messages),
@@ -181,6 +226,9 @@ export class LlmClient {
181
226
  temperature: this.opts.temperature,
182
227
  max_tokens: this.opts.maxTokens,
183
228
  stream: true,
229
+ // OpenAI-compatible providers only include usage in streaming responses
230
+ // when this flag is set — without it token accounting silently zeros out.
231
+ stream_options: { include_usage: true },
184
232
  });
185
233
  let res;
186
234
  try {
@@ -203,15 +251,47 @@ export class LlmClient {
203
251
  if (!res.body) {
204
252
  throw new LlmError("Response body is not readable (not a streaming response)");
205
253
  }
254
+ // Headers received — the WHOLE-request timeout has served its purpose.
255
+ // A long stream is normal (big models can stream for minutes), so from
256
+ // here on we enforce an IDLE timeout instead: reset on every received
257
+ // chunk; only a truly silent connection for idleTimeoutMs kills the read.
258
+ // (The old whole-request timer fired mid-stream on long generations —
259
+ // "LLM stream timed out while reading the response".) SIGINT still aborts
260
+ // through the same signal — stopTimer keeps the external listener alive.
261
+ stopTimer();
262
+ const idleTimeoutMs = opts.idleTimeoutMs ?? this.timeoutMs;
263
+ const readWithIdleTimeout = async () => {
264
+ const ac = new AbortController();
265
+ const t = setTimeout(() => ac.abort(new DOMException(`stream idle for ${idleTimeoutMs / 1000}s`, "TimeoutError")), idleTimeoutMs);
266
+ try {
267
+ return await Promise.race([
268
+ reader.read(),
269
+ new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(ac.signal.reason), { once: true })),
270
+ ]);
271
+ }
272
+ finally {
273
+ clearTimeout(t);
274
+ }
275
+ };
206
276
  // Accumulators for tool_calls across chunks
207
277
  const accumulatedToolCalls = [];
208
278
  let finalResult;
279
+ let finalUsage;
209
280
  const reader = res.body.getReader();
210
281
  const decoder = new TextDecoder();
211
282
  let buffer = "";
283
+ // Reset the partial-call record for this stream (H-09 continuation).
284
+ this.lastPartialToolCalls = [];
285
+ // I-05: accumulate reasoning deltas for the final message.
286
+ let reasoningAccum = "";
212
287
  try {
213
288
  while (true) {
214
- const { done, value } = await reader.read();
289
+ // Guard the pending read against a late abort (e.g. the idle timeout
290
+ // firing while the consumer paused at a yield): attaching a no-op
291
+ // catch prevents an unhandled rejection from crashing the process.
292
+ const pending = readWithIdleTimeout();
293
+ pending.catch(() => { });
294
+ const { done, value } = await pending;
215
295
  if (done)
216
296
  break;
217
297
  buffer += decoder.decode(value, { stream: true });
@@ -229,6 +309,10 @@ export class LlmClient {
229
309
  catch {
230
310
  continue; // skip malformed JSON lines
231
311
  }
312
+ // Usage-only final chunk: many providers send usage in a chunk with
313
+ // an empty choices array — must not be skipped.
314
+ if (chunkData.usage)
315
+ finalUsage = { ...chunkData.usage };
232
316
  const choice = chunkData.choices?.[0];
233
317
  if (!choice)
234
318
  continue;
@@ -237,6 +321,11 @@ export class LlmClient {
237
321
  if (delta?.content != null && delta.content.length > 0) {
238
322
  yield { contentDelta: delta.content, done: false };
239
323
  }
324
+ // I-05: reasoning_content delta (DeepSeek/OpenRouter style)
325
+ if (delta?.reasoning_content) {
326
+ reasoningAccum += delta.reasoning_content;
327
+ yield { reasoningDelta: delta.reasoning_content, done: false };
328
+ }
240
329
  // Tool call deltas
241
330
  if (delta?.tool_calls) {
242
331
  for (const tc of delta.tool_calls) {
@@ -278,6 +367,7 @@ export class LlmClient {
278
367
  content: null,
279
368
  stop_reason: choice.finish_reason,
280
369
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
370
+ reasoning: reasoningAccum || undefined,
281
371
  },
282
372
  usage: chunkData.usage,
283
373
  };
@@ -285,15 +375,39 @@ export class LlmClient {
285
375
  }
286
376
  }
287
377
  }
378
+ catch (err) {
379
+ // Normalize the abort timeout into LlmError so callers handle it as a
380
+ // normal provider failure instead of a stray DOMException.
381
+ const e = err;
382
+ if (e?.name === "TimeoutError" || /TimeoutError/.test(e?.message ?? "")) {
383
+ throw new LlmError(`LLM stream timed out (${(e?.message ?? "").includes("idle") ? `no data for ${e.message.replace(/^.*idle for /, "")}` : "while reading the response"})`);
384
+ }
385
+ throw err;
386
+ }
288
387
  finally {
289
- // Cancel the body (frees the socket) and clear the request timeout
388
+ // H-09: record complete tool calls accumulated so far, for continuation.
389
+ this.lastPartialToolCalls = accumulatedToolCalls
390
+ .filter((tc) => tc.name)
391
+ .map((tc) => ({ id: tc.id ?? "", name: tc.name, arguments: tc.arguments }));
392
+ // Cancel the body (frees the socket) and clear the request timeout.
393
+ // cancel() returns a promise — a rejection after abort must be swallowed
394
+ // or it becomes an unhandled rejection that kills the process.
290
395
  try {
291
- reader.cancel();
396
+ const c = reader.cancel();
397
+ if (c && typeof c.catch === "function")
398
+ c.catch(() => { });
292
399
  }
293
400
  catch { /* already released */ }
294
- reader.releaseLock();
401
+ try {
402
+ reader.releaseLock();
403
+ }
404
+ catch { /* lock already released */ }
295
405
  cleanup();
296
406
  }
407
+ // Attach usage collected from a usage-only chunk (choices: []) if the
408
+ // finish chunk itself didn't carry it.
409
+ if (finalResult && !finalResult.usage && finalUsage)
410
+ finalResult.usage = finalUsage;
297
411
  yield { done: true, final: finalResult };
298
412
  }
299
413
  }