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 +86 -20
- package/dist/agent.js +131 -49
- package/dist/build.js +6 -8
- package/dist/config.js +26 -2
- package/dist/constants.js +69 -0
- package/dist/cost.js +141 -0
- package/dist/index.js +73 -0
- package/dist/llm.js +11 -1
- package/dist/permissions.js +24 -3
- package/dist/provider-anthropic.js +14 -4
- package/dist/server.js +256 -0
- package/dist/tools.js +43 -6
- package/dist/ui.js +76 -10
- package/dist/webfetch.js +251 -0
- package/package.json +4 -2
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
|
|
143
|
+
usage,
|
|
134
144
|
};
|
|
135
145
|
}
|
|
136
146
|
// ── Streaming ───────────────────────────────────────────────────────────────
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
-
/
|
|
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
|
|
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
|
+
}
|