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/dist/cost.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Usage & cost tracking (enterprise feature).
3
+ *
4
+ * Every LLM call writes an `llm_usage` event into the session JSONL log:
5
+ * { ts, type: "llm_usage", model, provider, usage: { input_tokens, output_tokens, cached_tokens } }
6
+ *
7
+ * `scoder cost` reads those logs and aggregates tokens + estimated cost.
8
+ * All logic here is pure/filesystem-only so it stays unit-testable.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { resolveModelPricing } from "./constants.js";
13
+ /** Extract `llm_usage` events from a list of raw log events. */
14
+ export function extractUsageEvents(sessionId, events) {
15
+ const out = [];
16
+ for (const ev of events) {
17
+ if (ev.type !== "llm_usage")
18
+ continue;
19
+ const usage = (ev.usage ?? {});
20
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
21
+ out.push({
22
+ ts: typeof ev.ts === "string" ? ev.ts : undefined,
23
+ sessionId,
24
+ model: typeof ev.model === "string" ? ev.model : "unknown",
25
+ provider: typeof ev.provider === "string" ? ev.provider : undefined,
26
+ inputTokens: num(usage.input_tokens ?? usage.prompt_tokens),
27
+ outputTokens: num(usage.output_tokens ?? usage.completion_tokens),
28
+ cachedTokens: num(usage.cached_tokens),
29
+ });
30
+ }
31
+ return out;
32
+ }
33
+ /** Cost of one usage event in USD (cached tokens are billed like input tokens here — provider-specific discounts are approximated by the price table). */
34
+ export function estimateEventCost(ev, overrides = {}) {
35
+ const p = resolveModelPricing(ev.model, overrides);
36
+ return (ev.inputTokens / 1_000_000) * p.input + (ev.outputTokens / 1_000_000) * p.output;
37
+ }
38
+ /** Aggregate usage events into totals + estimated cost. */
39
+ export function aggregateUsage(events, overrides = {}) {
40
+ const agg = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0 };
41
+ for (const ev of events) {
42
+ agg.calls += 1;
43
+ agg.inputTokens += ev.inputTokens;
44
+ agg.outputTokens += ev.outputTokens;
45
+ agg.cachedTokens += ev.cachedTokens;
46
+ agg.costUsd += estimateEventCost(ev, overrides);
47
+ }
48
+ agg.costUsd = Math.round(agg.costUsd * 10_000) / 10_000; // round to $0.0001
49
+ return agg;
50
+ }
51
+ /** Parse a JSONL log file into raw events (null if missing/unreadable). */
52
+ export function readJsonlLog(path) {
53
+ let content;
54
+ try {
55
+ content = readFileSync(path, "utf8");
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ const events = [];
61
+ for (const line of content.split("\n")) {
62
+ if (!line.trim())
63
+ continue;
64
+ try {
65
+ events.push(JSON.parse(line));
66
+ }
67
+ catch {
68
+ // skip malformed lines
69
+ }
70
+ }
71
+ return events;
72
+ }
73
+ /** List all session log files, newest id first (ids sort chronologically). */
74
+ export function listLogSessions(workdir) {
75
+ const dir = resolve(workdir, ".scoder/logs");
76
+ if (!existsSync(dir))
77
+ return [];
78
+ try {
79
+ return readdirSync(dir)
80
+ .filter((f) => f.endsWith(".jsonl"))
81
+ .map((f) => f.slice(0, -".jsonl".length))
82
+ .sort()
83
+ .reverse();
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ /** Load all usage events of a single session (or [] if no log). */
90
+ export function loadSessionUsage(workdir, sessionId) {
91
+ const path = resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
92
+ const events = readJsonlLog(path);
93
+ return events ? extractUsageEvents(sessionId, events) : [];
94
+ }
95
+ /**
96
+ * Collect usage for a selection of sessions:
97
+ * - explicit id -> that session
98
+ * - "last n" -> n most recent session logs
99
+ * - all -> every session log in the workdir
100
+ */
101
+ export function collectUsage(workdir, opts = {}) {
102
+ let ids;
103
+ if (opts.sessionId) {
104
+ ids = [opts.sessionId];
105
+ }
106
+ else if (typeof opts.last === "number") {
107
+ const all = listLogSessions(workdir);
108
+ ids = all.slice(0, Math.max(0, opts.last));
109
+ }
110
+ else {
111
+ ids = listLogSessions(workdir);
112
+ }
113
+ return ids.map((id) => ({
114
+ sessionId: id,
115
+ aggregate: aggregateUsage(loadSessionUsage(workdir, id), opts.pricingOverrides),
116
+ }));
117
+ }
118
+ /** Render a human-readable cost report (also used by tests to assert output). */
119
+ export function formatCostReport(sessionCosts) {
120
+ if (sessionCosts.length === 0) {
121
+ return "No LLM usage recorded. Run with --log-file (or SCODER_LOG_FILE=1) to record usage.";
122
+ }
123
+ const grand = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0 };
124
+ const lines = ["COST REPORT"];
125
+ for (const sc of sessionCosts) {
126
+ const a = sc.aggregate;
127
+ grand.calls += a.calls;
128
+ grand.inputTokens += a.inputTokens;
129
+ grand.outputTokens += a.outputTokens;
130
+ grand.cachedTokens += a.cachedTokens;
131
+ grand.costUsd += a.costUsd;
132
+ lines.push(` ${sc.sessionId} calls=${a.calls} in=${a.inputTokens.toLocaleString("en-US")} out=${a.outputTokens.toLocaleString("en-US")} cached=${a.cachedTokens.toLocaleString("en-US")} cost≈$${a.costUsd.toFixed(4)}`);
133
+ }
134
+ grand.costUsd = Math.round(grand.costUsd * 10_000) / 10_000;
135
+ lines.push(`TOTAL (${sessionCosts.length} session(s)) calls=${grand.calls} in=${grand.inputTokens.toLocaleString("en-US")} out=${grand.outputTokens.toLocaleString("en-US")} cached=${grand.cachedTokens.toLocaleString("en-US")} cost≈$${grand.costUsd.toFixed(4)}`);
136
+ return lines.join("\n");
137
+ }
138
+ /** Join a path under the workdir's log dir (exported for tests). */
139
+ export function logFilePath(workdir, sessionId) {
140
+ return join(workdir, ".scoder/logs", `${sessionId}.jsonl`);
141
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
6
6
  import { Agent } from "./agent.js";
7
7
  import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
8
8
  import { JsonlLogger } from "./logger.js";
9
+ import { collectUsage, formatCostReport } from "./cost.js";
9
10
  import { getVersion as getPackageVersion } from "./version.js";
10
11
  import * as ui from "./ui.js";
11
12
  // Single source of truth: read version from package.json (see src/version.ts).
@@ -18,6 +19,11 @@ USAGE
18
19
  scoder list List saved sessions for this directory
19
20
  scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
20
21
  scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
22
+ scoder cost [--session <id>] [--last <n>]
23
+ Token usage & estimated cost from JSONL logs
24
+ scoder serve [--port <n>] [--host <addr>]
25
+ Run as an HTTP server: POST tasks, stream
26
+ progress via Server-Sent Events (see below)
21
27
  scoder --version | --help
22
28
 
23
29
  OPTIONS
@@ -33,6 +39,8 @@ OPTIONS
33
39
  --no-save Disable session auto-save to .scoder/sessions/
34
40
  --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
35
41
  (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
42
+ --session <id> (cost) Aggregate usage of one specific session
43
+ --last <n> (cost) Aggregate usage of the n most recent sessions
36
44
  -h, --help Show this help
37
45
  -v, --version Show version
38
46
 
@@ -96,6 +104,17 @@ function parseArgs(argv) {
96
104
  case "--log-file":
97
105
  args.logFile = true;
98
106
  break;
107
+ case "--session":
108
+ args.costSession = next();
109
+ break;
110
+ case "--last": {
111
+ const n = Number(next());
112
+ if (!Number.isFinite(n) || n < 1) {
113
+ throw new Error(`Invalid --last value (expected a positive integer): ${n}`);
114
+ }
115
+ args.costLast = Math.floor(n);
116
+ break;
117
+ }
99
118
  case "--provider": {
100
119
  const p = next();
101
120
  if (p !== "openai" && p !== "anthropic") {
@@ -104,6 +123,17 @@ function parseArgs(argv) {
104
123
  args.provider = p;
105
124
  break;
106
125
  }
126
+ case "--port": {
127
+ const n = Number(next());
128
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
129
+ throw new Error(`Invalid --port value (expected 0-65535): ${n}`);
130
+ }
131
+ args.servePort = n;
132
+ break;
133
+ }
134
+ case "--host":
135
+ args.serveHost = next();
136
+ break;
107
137
  case "-h":
108
138
  case "--help":
109
139
  args.help = true;
@@ -135,6 +165,14 @@ function parseArgs(argv) {
135
165
  args.logsId = rest[1] ?? "last";
136
166
  args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
137
167
  }
168
+ else if (rest[0] === "cost") {
169
+ args.subcommand = "cost";
170
+ args.task = null;
171
+ }
172
+ else if (rest[0] === "serve") {
173
+ args.subcommand = "serve";
174
+ args.task = null;
175
+ }
138
176
  return args;
139
177
  }
140
178
  async function runOnce(cfg, task) {
@@ -273,6 +311,41 @@ async function main() {
273
311
  }
274
312
  return;
275
313
  }
314
+ if (args.subcommand === "cost") {
315
+ const sessionCosts = collectUsage(cfg.workdir, {
316
+ sessionId: args.costSession,
317
+ last: args.costLast,
318
+ pricingOverrides: cfg.modelPricing,
319
+ });
320
+ console.log(formatCostReport(sessionCosts));
321
+ return;
322
+ }
323
+ if (args.subcommand === "serve") {
324
+ const { startServer } = await import("./server.js");
325
+ if (!cfg.apiKey) {
326
+ ui.warn("No API key configured — runs will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
327
+ }
328
+ const srv = await startServer(cfg, { host: args.serveHost, port: args.servePort });
329
+ const token = process.env.SCODER_SERVER_TOKEN ? " · token auth" : "";
330
+ ui.banner(`scoder serve @ http://${args.serveHost ?? "127.0.0.1"}:${srv.port}${token}`, [
331
+ ["task", `POST /api/runs { "task": "..." } → 202 { id, eventsUrl }`],
332
+ ["events", `GET /api/runs/<id>/events → SSE stream`],
333
+ ["list", `GET /api/runs`],
334
+ ["abort", `POST /api/runs/<id>/abort`],
335
+ ["workdir", cfg.workdir],
336
+ ["model", `${cfg.model} · permission ${cfg.permissionMode}`],
337
+ ]);
338
+ const shutdown = async () => {
339
+ ui.info("shutting down server...");
340
+ await srv.close();
341
+ process.exit(0);
342
+ };
343
+ process.on("SIGINT", () => {
344
+ ui.warn("\nSIGINT — shutting down server, aborting in-flight runs...");
345
+ void shutdown();
346
+ });
347
+ return;
348
+ }
276
349
  if (args.task) {
277
350
  const code = await runOnce(cfg, args.task);
278
351
  process.exit(code);
package/dist/llm.js CHANGED
@@ -119,6 +119,16 @@ export class LlmClient {
119
119
  const choice = data.choices?.[0]?.message;
120
120
  if (!choice)
121
121
  throw new LlmError("LLM API returned no message in response");
122
+ // Extract cached prompt tokens when the provider reports them
123
+ // (OpenAI-style: usage.prompt_tokens_details.cached_tokens).
124
+ let usage;
125
+ if (data.usage) {
126
+ usage = { ...data.usage };
127
+ const details = usage.prompt_tokens_details;
128
+ if (usage.cached_tokens === undefined && typeof details?.cached_tokens === "number") {
129
+ usage.cached_tokens = details.cached_tokens;
130
+ }
131
+ }
122
132
  return {
123
133
  message: {
124
134
  role: "assistant",
@@ -130,7 +140,7 @@ export class LlmClient {
130
140
  arguments: tc.function?.arguments ?? "",
131
141
  })),
132
142
  },
133
- usage: data.usage,
143
+ usage,
134
144
  };
135
145
  }
136
146
  // ── Streaming ───────────────────────────────────────────────────────────────
@@ -10,9 +10,30 @@ import { createInterface } from "node:readline";
10
10
  // Catastrophic command patterns — always denied, no exceptions
11
11
  // ---------------------------------------------------------------------------
12
12
  const CATASTROPHIC_PATTERNS = [
13
+ // rm -rf (original) — kept for backwards compatibility
13
14
  /rm\s+-rf\s+[/~"]|(?:rm\s+-rf\s+\/\*)/i,
14
- /mkfs\b/i,
15
+ // rm with whitespace/flag variants: "rm -rf /", "rm -fr /", "rm -r -f /",
16
+ // "rm --recursive --force /", "rm -rvf /" … i.e. any rm followed by one or
17
+ // more option flags and then a target at filesystem root or home.
18
+ // Does NOT match common safe commands: "rm -rf node_modules", "rm file.txt",
19
+ // "rm ./build" (target must START with / or ~).
20
+ /\brm\s+(?:-{1,2}[\w-]+\s+)+["']?[/~](?:\s|$|\/)/,
21
+ // rm with a single flag token containing both r/R and f (e.g. -fr, -Rf, -rvf)
22
+ // targeting / or ~ — covered above, but also catch "rm -fr --no-preserve-root".
23
+ /\brm\s+[^|;&>]*--no-preserve-root/i,
24
+ // base64/sh -c wrappers used to smuggle destructive commands past filters:
25
+ // echo cm0gLXJmIC8= | base64 -d | sh
26
+ // sh -c "rm -rf /"
27
+ /base64\s+(?:-d|-D|--decode)\b[^|;&]*\|\s*(?:ba|z|da)?sh\b/i,
28
+ /\b(?:ba|z|da)?sh\s+-c\s+["'][^"']*\brm\s+[^"']*\s-[a-zA-Z]*[rf]/i,
29
+ // Filesystem destruction
30
+ /\bmkfs(?:\.[a-z0-9]+)?\b/i,
15
31
  /dd\s+.*of=\//i,
32
+ /chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
33
+ // chmod -R 777 on / or ~ (any flag spelling: -R, -r, -Rf, --recursive)
34
+ /\bchmod\s+(?:-{1,2}[\w-]+\s+)+[0-7]{3,4}\s+["']?[/~](?:\s|$|\/)/,
35
+ // Redirect raw writes to block devices: > /dev/sda, > /dev/nvme0n1, dd > disk...
36
+ />\s*\/dev\/(?:sd[a-z]+|hd[a-z]+|nvme\d+n\d+(?:p\d+)?|disk\d|rdisk\d)/i,
16
37
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/i,
17
38
  // curl/wget pipe-to-shell: match any content between command and '| sh'/'| bash'
18
39
  // Handles -qO-, -X POST, etc. flags; URL-like patterns must contain '/' or '.'
@@ -20,9 +41,9 @@ const CATASTROPHIC_PATTERNS = [
20
41
  /curl\s+.*[/.].*\s*\|\s*bash\b/i,
21
42
  /wget\s+.*[/.].*\s*\|\s*sh\b/i,
22
43
  /wget\s+.*[/.].*\s*\|\s*bash\b/i,
23
- /sudo\s+rm\s+-rf/i,
44
+ // sudo rm with any recursive/force flag combo: "sudo rm -rf x", "sudo rm -fr /usr"
45
+ /sudo\s+rm\s+[^|;&]*-\w*[rR]/i,
24
46
  /\bshutdown\b|\breboot\b|\binit\s+[06]\b/i,
25
- /chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
26
47
  />\s*\/dev\/sda/i,
27
48
  /\|\s*nc\s+-e\s+/i,
28
49
  /\|\s*bash\s+-i/i,
@@ -72,16 +72,20 @@ export function fromAnthropicResponse(data) {
72
72
  });
73
73
  }
74
74
  }
75
+ const usage = {
76
+ prompt_tokens: data.usage?.input_tokens,
77
+ completion_tokens: data.usage?.output_tokens,
78
+ };
79
+ if (typeof data.usage?.cache_read_input_tokens === "number") {
80
+ usage.cached_tokens = data.usage.cache_read_input_tokens;
81
+ }
75
82
  return {
76
83
  message: {
77
84
  role: "assistant",
78
85
  content: text.length > 0 ? text : null,
79
86
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
80
87
  },
81
- usage: {
82
- prompt_tokens: data.usage?.input_tokens,
83
- completion_tokens: data.usage?.output_tokens,
84
- },
88
+ usage,
85
89
  };
86
90
  }
87
91
  // ---------------------------------------------------------------------------
@@ -216,6 +220,9 @@ export class AnthropicClient {
216
220
  prompt_tokens: ev.message.usage.input_tokens,
217
221
  completion_tokens: ev.message.usage.output_tokens,
218
222
  };
223
+ if (typeof ev.message.usage.cache_read_input_tokens === "number") {
224
+ usage.cached_tokens = ev.message.usage.cache_read_input_tokens;
225
+ }
219
226
  }
220
227
  return null;
221
228
  }
@@ -252,6 +259,9 @@ export class AnthropicClient {
252
259
  prompt_tokens: usage?.prompt_tokens ?? ev.usage.input_tokens,
253
260
  completion_tokens: ev.usage.output_tokens ?? usage?.completion_tokens,
254
261
  };
262
+ if (usage?.cached_tokens === undefined && typeof ev.usage.cache_read_input_tokens === "number") {
263
+ usage = { ...usage, cached_tokens: ev.usage.cache_read_input_tokens };
264
+ }
255
265
  }
256
266
  return null;
257
267
  }
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
+ }