s-app-scoder-agent 0.3.0 → 0.4.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/agent.js CHANGED
@@ -43,6 +43,8 @@ export class Agent {
43
43
  sessionCreatedAt;
44
44
  currentTask = "";
45
45
  lastBuildResult = null;
46
+ /** Accumulated token usage across the current run (session state). */
47
+ usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
46
48
  mcp = null;
47
49
  /** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
48
50
  runSignal = new AbortController();
@@ -90,6 +92,55 @@ export class Agent {
90
92
  // terminate its child process immediately (long builds must be interruptible).
91
93
  this.ctx.signal = this.runSignal.signal;
92
94
  }
95
+ /** Push one tool-result message back into the conversation. */
96
+ pushToolResult(tc, content) {
97
+ this.messages.push({
98
+ role: "tool",
99
+ tool_call_id: tc.id,
100
+ name: tc.name,
101
+ content,
102
+ });
103
+ }
104
+ /**
105
+ * Handle a rejected tool call (unknown tool, policy denial, user denial):
106
+ * render the failure, log the event, and push an ERROR tool message so the
107
+ * LLM can react. Replaces the previously duplicated denied/deny/unknown blocks.
108
+ */
109
+ rejectToolCall(step, tc, output, logExtra) {
110
+ ui.toolResult(tc.name, false, output);
111
+ this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
112
+ this.pushToolResult(tc, output);
113
+ }
114
+ /**
115
+ * Record usage of one LLM call: render token counts, log an `llm_usage`
116
+ * event, and accumulate totals into session state.
117
+ */
118
+ recordLlmUsage(usage) {
119
+ if (!usage)
120
+ return;
121
+ const input = usage.prompt_tokens ?? 0;
122
+ const output = usage.completion_tokens ?? 0;
123
+ const cached = usage.cached_tokens ?? 0;
124
+ this.usageTotals.calls += 1;
125
+ this.usageTotals.inputTokens += input;
126
+ this.usageTotals.outputTokens += output;
127
+ this.usageTotals.cachedTokens += cached;
128
+ ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
129
+ this.logger.log({
130
+ type: "llm_usage",
131
+ model: this.client.model,
132
+ provider: this.config.provider,
133
+ usage: {
134
+ input_tokens: input,
135
+ output_tokens: output,
136
+ cached_tokens: cached,
137
+ },
138
+ });
139
+ }
140
+ /** Reset per-run usage accumulation (called when a fresh run starts). */
141
+ resetUsageTotals() {
142
+ this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
143
+ }
93
144
  /**
94
145
  * One LLM turn: streaming if enabled (renders deltas live), with automatic
95
146
  * fallback to non-streaming when the provider/stream fails.
@@ -171,6 +222,7 @@ export class Agent {
171
222
  ui.info(`model=${this.client.model} workdir=${this.config.workdir} build="${buildCmd}" maxSteps=${this.config.maxSteps}`);
172
223
  this.currentTask = task;
173
224
  this.resetRunSignal();
225
+ this.resetUsageTotals();
174
226
  this.messages = [
175
227
  {
176
228
  role: "system",
@@ -199,6 +251,7 @@ export class Agent {
199
251
  const buildCmd = this.buildCmd;
200
252
  this.currentTask = `${this.currentTask}\n${task}`.trim();
201
253
  this.resetRunSignal();
254
+ this.resetUsageTotals();
202
255
  this.messages.push({ role: "user", content: task });
203
256
  try {
204
257
  await this.connectMcp();
@@ -217,6 +270,7 @@ export class Agent {
217
270
  this.sessionCreatedAt = session.createdAt;
218
271
  this.currentTask = session.task;
219
272
  this.resetRunSignal();
273
+ this.resetUsageTotals();
220
274
  this.messages = session.messages ?? [];
221
275
  ui.header(`RESUME ${session.id}`);
222
276
  ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
@@ -262,19 +316,13 @@ export class Agent {
262
316
  });
263
317
  if (this.aborted)
264
318
  break;
265
- if (usage) {
266
- ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
267
- this.logger.log({ type: "step", step: steps, usage });
268
- }
269
- else {
270
- this.logger.log({ type: "step", step: steps });
271
- }
319
+ this.recordLlmUsage(usage);
320
+ this.logger.log({ type: "step", step: steps });
272
321
  // No tool calls => assistant produced a text answer
273
322
  if (!message.tool_calls || message.tool_calls.length === 0) {
274
323
  const text = message.content ?? "";
275
324
  this.messages.push({ role: "assistant", content: text });
276
325
  // If the assistant says it's done without building, force a build check.
277
- // If the assistant says it's done without building, force a build check.
278
326
  if (claimsCompletion(text) && steps < this.config.maxSteps) {
279
327
  ui.info("agent claims completion -> verifying with build");
280
328
  lastBuild = await this.checkBuild(buildCmd);
@@ -311,24 +359,12 @@ export class Agent {
311
359
  args = tc.arguments ? JSON.parse(tc.arguments) : {};
312
360
  }
313
361
  catch {
314
- this.messages.push({
315
- role: "tool",
316
- tool_call_id: tc.id,
317
- name: tc.name,
318
- content: `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`,
319
- });
362
+ this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
320
363
  continue;
321
364
  }
322
365
  ui.toolCall(tc.name, args);
323
366
  if (!tool && !(this.mcp?.hasTool(tc.name))) {
324
- ui.toolResult(tc.name, false, "unknown tool");
325
- this.logger.log({ type: "tool", step: steps, tool: tc.name, ok: false, error: "unknown tool" });
326
- this.messages.push({
327
- role: "tool",
328
- tool_call_id: tc.id,
329
- name: tc.name,
330
- content: `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`,
331
- });
367
+ this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
332
368
  continue;
333
369
  }
334
370
  let result;
@@ -341,14 +377,7 @@ export class Agent {
341
377
  ok: false,
342
378
  output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
343
379
  };
344
- ui.toolResult(tc.name, false, result.output);
345
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: decision.reason });
346
- this.messages.push({
347
- role: "tool",
348
- tool_call_id: tc.id,
349
- name: tc.name,
350
- content: result.output,
351
- });
380
+ this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
352
381
  continue;
353
382
  }
354
383
  if (decision.decision === "ask") {
@@ -358,14 +387,7 @@ export class Agent {
358
387
  ok: false,
359
388
  output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
360
389
  };
361
- ui.toolResult(tc.name, false, result.output);
362
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: "user" });
363
- this.messages.push({
364
- role: "tool",
365
- tool_call_id: tc.id,
366
- name: tc.name,
367
- content: result.output,
368
- });
390
+ this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
369
391
  continue;
370
392
  }
371
393
  }
@@ -406,12 +428,7 @@ export class Agent {
406
428
  }
407
429
  ui.toolResult(tc.name, result.ok, result.output);
408
430
  stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
409
- this.messages.push({
410
- role: "tool",
411
- tool_call_id: tc.id,
412
- name: tc.name,
413
- content: summariseOutput(result.output),
414
- });
431
+ this.pushToolResult(tc, summariseOutput(result.output));
415
432
  }
416
433
  // After a build-command run, treat success as the terminal condition.
417
434
  // Match the FULL build command (not just its first word): matching "npm"
@@ -456,6 +473,12 @@ export class Agent {
456
473
  lastBuild: lastBuild
457
474
  ? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
458
475
  : null,
476
+ usage: {
477
+ llmCalls: this.usageTotals.calls,
478
+ inputTokens: this.usageTotals.inputTokens,
479
+ outputTokens: this.usageTotals.outputTokens,
480
+ cachedTokens: this.usageTotals.cachedTokens,
481
+ },
459
482
  }, this.sessionCreatedAt);
460
483
  }
461
484
  /** Explicitly verify the build (independent of what the LLM claims). */
package/dist/build.js CHANGED
@@ -10,8 +10,9 @@ export function detectBuildCommand(workdir) {
10
10
  if (pkg.scripts?.build)
11
11
  return "npm run build";
12
12
  }
13
- catch {
14
- // fall through
13
+ catch (err) {
14
+ // malformed package.json -> fall through to the other detectors
15
+ console.error(`⚠ cannot parse package.json (${err.message}) — build command detection degraded`);
15
16
  }
16
17
  }
17
18
  if (existsSync(resolve(workdir, "Makefile")))
@@ -26,10 +27,6 @@ export function detectBuildCommand(workdir) {
26
27
  return "npx tsc --noEmit";
27
28
  return null;
28
29
  }
29
- /**
30
- * Run the build command. This is the agent's stop condition:
31
- * exit code 0 == task complete.
32
- */
33
30
  /**
34
31
  * Run the build command. This is the agent's stop condition:
35
32
  * exit code 0 == task complete.
package/dist/config.js CHANGED
@@ -11,8 +11,9 @@ function loadRcFile(workdir) {
11
11
  try {
12
12
  return JSON.parse(readFileSync(p, "utf8"));
13
13
  }
14
- catch {
15
- // malformed rc file -> ignore
14
+ catch (err) {
15
+ // malformed rc file -> ignore it, but tell the user why their config is not applied
16
+ console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
16
17
  }
17
18
  }
18
19
  }
@@ -28,6 +29,28 @@ function parseList(raw) {
28
29
  return [];
29
30
  return raw.filter((x) => typeof x === "string");
30
31
  }
32
+ /** Validate the user-supplied pricing table; drop malformed entries with a warning. */
33
+ function parsePricing(raw) {
34
+ if (raw === undefined)
35
+ return undefined;
36
+ if (typeof raw !== "object" || raw === null) {
37
+ console.error("⚠ ignoring invalid modelPricing in config (expected an object)");
38
+ return undefined;
39
+ }
40
+ const out = {};
41
+ for (const key of Object.keys(raw)) {
42
+ const p = raw[key];
43
+ if (typeof p === "object" && p !== null &&
44
+ typeof p.input === "number" && Number.isFinite(p.input) &&
45
+ typeof p.output === "number" && Number.isFinite(p.output)) {
46
+ out[key] = { input: p.input, output: p.output };
47
+ }
48
+ else {
49
+ console.error(`⚠ ignoring invalid modelPricing entry "${key}" (expected { input: number, output: number })`);
50
+ }
51
+ }
52
+ return out;
53
+ }
31
54
  export function loadConfig(overrides = {}) {
32
55
  const workdir = overrides.workdir ?? process.cwd();
33
56
  const rc = loadRcFile(workdir);
@@ -74,6 +97,7 @@ export function loadConfig(overrides = {}) {
74
97
  stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
75
98
  permissionMode: overrides.permissionMode ??
76
99
  parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
100
+ modelPricing: parsePricing(rc.modelPricing),
77
101
  };
78
102
  // Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
79
103
  if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Central constants: model pricing for cost estimation.
3
+ *
4
+ * Prices are USD per 1M tokens (input / output), approximate list prices.
5
+ * They can be overridden per-project via the rc file:
6
+ *
7
+ * .scoderrc.json:
8
+ * { "modelPricing": { "my-model": { "input": 1.0, "output": 2.0 } } }
9
+ *
10
+ * Keys are matched as a PREFIX of the model name (case-insensitive), so
11
+ * "gpt-4o-mini" also covers "gpt-4o-mini-2024-07-18". More specific keys
12
+ * (longer prefix) win.
13
+ */
14
+ /** Fallback price when a model is unknown (deliberately conservative, not zero). */
15
+ export const DEFAULT_MODEL_PRICING = { input: 0.5, output: 1.5 };
16
+ /** Approximate list prices, USD per 1M tokens. Longest matching prefix wins. */
17
+ export const MODEL_PRICING = {
18
+ // OpenAI
19
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
20
+ "gpt-4o": { input: 2.5, output: 10 },
21
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
22
+ "gpt-4.1": { input: 2, output: 8 },
23
+ "gpt-4-turbo": { input: 10, output: 30 },
24
+ "gpt-4": { input: 30, output: 60 },
25
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
26
+ "o1": { input: 15, output: 60 },
27
+ "o1-mini": { input: 1.1, output: 4.4 },
28
+ "o3-mini": { input: 1.1, output: 4.4 },
29
+ "o3": { input: 2, output: 8 },
30
+ "o4-mini": { input: 1.1, output: 4.4 },
31
+ // Anthropic
32
+ "claude-opus-4": { input: 15, output: 75 },
33
+ "claude-sonnet-4": { input: 3, output: 15 },
34
+ "claude-haiku-4": { input: 0.8, output: 4 },
35
+ "claude-3-7-sonnet": { input: 3, output: 15 },
36
+ "claude-3-5-sonnet": { input: 3, output: 15 },
37
+ "claude-3-5-haiku": { input: 0.8, output: 4 },
38
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
39
+ "claude-3-opus": { input: 15, output: 75 },
40
+ // Popular local/OpenAI-compatible providers (mostly $0 or nominal)
41
+ "llama3": { input: 0, output: 0 },
42
+ "qwen": { input: 0, output: 0 },
43
+ "deepseek-chat": { input: 0.27, output: 1.1 },
44
+ "deepseek-reasoner": { input: 0.55, output: 2.19 },
45
+ };
46
+ /**
47
+ * Resolve the price table for a model, honouring user overrides.
48
+ * `overrides` maps model-name prefixes to pricing and always wins over the
49
+ * built-in table. Longest matching prefix wins (case-insensitive).
50
+ */
51
+ export function resolveModelPricing(model, overrides = {}) {
52
+ const m = model.toLowerCase();
53
+ let best = null;
54
+ // Built-in table first (longest prefix wins), then overrides — an override
55
+ // with the SAME prefix length replaces the built-in entry.
56
+ for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
57
+ const k = key.toLowerCase();
58
+ if (m.startsWith(k) && (!best || k.length > best.key.length)) {
59
+ best = { key: k, pricing };
60
+ }
61
+ }
62
+ for (const [key, pricing] of Object.entries(overrides)) {
63
+ const k = key.toLowerCase();
64
+ if (m.startsWith(k) && (!best || k.length >= best.key.length)) {
65
+ best = { key: k, pricing };
66
+ }
67
+ }
68
+ return best ? best.pricing : DEFAULT_MODEL_PRICING;
69
+ }
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,8 @@ 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
21
24
  scoder --version | --help
22
25
 
23
26
  OPTIONS
@@ -33,6 +36,8 @@ OPTIONS
33
36
  --no-save Disable session auto-save to .scoder/sessions/
34
37
  --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
35
38
  (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
39
+ --session <id> (cost) Aggregate usage of one specific session
40
+ --last <n> (cost) Aggregate usage of the n most recent sessions
36
41
  -h, --help Show this help
37
42
  -v, --version Show version
38
43
 
@@ -96,6 +101,17 @@ function parseArgs(argv) {
96
101
  case "--log-file":
97
102
  args.logFile = true;
98
103
  break;
104
+ case "--session":
105
+ args.costSession = next();
106
+ break;
107
+ case "--last": {
108
+ const n = Number(next());
109
+ if (!Number.isFinite(n) || n < 1) {
110
+ throw new Error(`Invalid --last value (expected a positive integer): ${n}`);
111
+ }
112
+ args.costLast = Math.floor(n);
113
+ break;
114
+ }
99
115
  case "--provider": {
100
116
  const p = next();
101
117
  if (p !== "openai" && p !== "anthropic") {
@@ -135,6 +151,10 @@ function parseArgs(argv) {
135
151
  args.logsId = rest[1] ?? "last";
136
152
  args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
137
153
  }
154
+ else if (rest[0] === "cost") {
155
+ args.subcommand = "cost";
156
+ args.task = null;
157
+ }
138
158
  return args;
139
159
  }
140
160
  async function runOnce(cfg, task) {
@@ -273,6 +293,15 @@ async function main() {
273
293
  }
274
294
  return;
275
295
  }
296
+ if (args.subcommand === "cost") {
297
+ const sessionCosts = collectUsage(cfg.workdir, {
298
+ sessionId: args.costSession,
299
+ last: args.costLast,
300
+ pricingOverrides: cfg.modelPricing,
301
+ });
302
+ console.log(formatCostReport(sessionCosts));
303
+ return;
304
+ }
276
305
  if (args.task) {
277
306
  const code = await runOnce(cfg, args.task);
278
307
  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/tools.js CHANGED
@@ -25,8 +25,13 @@ function safePath(workdir, p) {
25
25
  try {
26
26
  realWorkdir = realpathSync(workdir);
27
27
  }
28
- catch {
29
- // workdir must exist; keep lexical fallback
28
+ catch (err) {
29
+ // workdir must exist; keep lexical fallback for a transient ENOENT,
30
+ // but surface unexpected failures instead of silently swallowing them.
31
+ const code = err.code;
32
+ if (code !== "ENOENT") {
33
+ throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
34
+ }
30
35
  }
31
36
  // Walk up to the nearest existing ancestor, then verify containment.
32
37
  let probe = abs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
+ "prepare": "npm run build",
13
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test",
12
14
  "start": "node dist/index.js",
13
15
  "dev": "tsc --watch",
14
16
  "typecheck": "tsc --noEmit",
@@ -48,4 +50,4 @@
48
50
  "typescript": "^5.6.0",
49
51
  "typescript-eslint": "^8.72.1"
50
52
  }
51
- }
53
+ }