runcap 0.1.1 → 0.2.1

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.
@@ -2,15 +2,19 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import http from "node:http";
4
4
  import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
- import { existsSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
6
  import path from "node:path";
7
7
  import process from "node:process";
8
+ import { syncRun } from "./cloud.mjs";
9
+ import { sendAlert } from "./alerts.mjs";
10
+ import { compressRequestBody, estimateTokens } from "./compressor.mjs";
8
11
 
9
12
  const STORE_DIR = ".runcap";
10
13
  const MISSIONS_DIR = path.join(STORE_DIR, "missions");
11
14
  const PLANS_DIR = path.join(STORE_DIR, "plans");
12
15
  const FUEL_FILE = path.join(STORE_DIR, "fuel.json");
13
16
  const GATEWAY_EVENTS_FILE = path.join(STORE_DIR, "gateway-events.jsonl");
17
+ const BUDGET_FILE = path.join(STORE_DIR, "budget.json");
14
18
  const ENV_EXAMPLE_FILE = ".env.example";
15
19
 
16
20
  const ERROR_PATTERNS = [
@@ -51,7 +55,7 @@ const ERROR_PATTERNS = [
51
55
  }
52
56
  ];
53
57
 
54
- export async function runMission({ command, label, fuelBefore }) {
58
+ export async function runMission({ command, label, fuelBefore, autoGateway = false, mock = false }) {
55
59
  await ensureStore();
56
60
  const id = createMissionId(label);
57
61
  const missionDir = path.join(MISSIONS_DIR, id);
@@ -61,7 +65,29 @@ export async function runMission({ command, label, fuelBefore }) {
61
65
  const cwd = process.cwd();
62
66
  const before = await collectSnapshot(cwd);
63
67
  const preflight = buildPreflight(command.join(" "), before);
64
- const output = await runChild(command, cwd);
68
+
69
+ // Zero-config: bring up a gateway for just this run and point the child's
70
+ // provider base URLs at it, so the cap is enforced without the user manually
71
+ // starting a gateway or exporting any base URL.
72
+ let gateway = null;
73
+ let childEnv = {};
74
+ const budgetBefore = readBudget();
75
+ const spentBefore = autoGateway ? (await readGatewaySummary({ windowMs: budgetWindowMs() })).estimatedCostUsd : 0;
76
+ if (autoGateway) {
77
+ gateway = await startEphemeralGateway({ mock });
78
+ childEnv = {
79
+ ANTHROPIC_BASE_URL: `${gateway.baseUrl}/v1`,
80
+ OPENAI_BASE_URL: `${gateway.baseUrl}/v1`,
81
+ OPENAI_API_BASE: `${gateway.baseUrl}/v1`
82
+ };
83
+ }
84
+
85
+ let output;
86
+ try {
87
+ output = await runChild(command, cwd, childEnv);
88
+ } finally {
89
+ if (gateway) await gateway.close().catch(() => {});
90
+ }
65
91
  const after = await collectSnapshot(cwd);
66
92
  const terminal = `${output.stdout}\n${output.stderr}`;
67
93
  const errors = parseErrors(terminal);
@@ -103,9 +129,21 @@ export async function runMission({ command, label, fuelBefore }) {
103
129
  await writeFile(path.join(missionDir, "report.html"), formatHtmlReport(mission));
104
130
  await writeFile(path.join(STORE_DIR, "latest"), id);
105
131
 
132
+ let capSummary = null;
133
+ if (autoGateway) {
134
+ const spentAfter = (await readGatewaySummary({ windowMs: budgetWindowMs() })).estimatedCostUsd;
135
+ capSummary = {
136
+ capUsd: budgetBefore,
137
+ spentThisRunUsd: Number((spentAfter - spentBefore).toFixed(6)),
138
+ spentWindowUsd: spentAfter,
139
+ mode: gateway?.gatewayMode ?? "proxy"
140
+ };
141
+ }
142
+
106
143
  return {
107
144
  id,
108
- summary: shortSummary(mission)
145
+ summary: shortSummary(mission),
146
+ capSummary
109
147
  };
110
148
  }
111
149
 
@@ -207,6 +245,48 @@ export async function planMission(goal, options = {}) {
207
245
  return plan;
208
246
  }
209
247
 
248
+ // Persist a hard cap to .runcap/budget.json so the gateway enforces it without
249
+ // the user manually exporting AIM_DAILY_BUDGET_USD. env still wins if set.
250
+ // Show a meaningful figure for sub-cent spend; a real call can cost a fraction
251
+ // of a cent, and rounding it to $0.00 reads as "nothing was recorded".
252
+ function fmtUsd(n) {
253
+ const v = Number(n);
254
+ if (!(v > 0)) return "$0.00";
255
+ if (v >= 0.01) return `$${v.toFixed(2)}`;
256
+ if (v >= 0.0001) return `$${v.toFixed(4)}`;
257
+ return `$${v.toPrecision(2)}`;
258
+ }
259
+
260
+ export async function setBudgetCap(capUsd, { source = "manual" } = {}) {
261
+ await ensureStore();
262
+ const value = Number(capUsd);
263
+ if (!Number.isFinite(value) || value < 0) {
264
+ throw new Error("Usage: runcap cap <usd> (a non-negative number).");
265
+ }
266
+ await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: value, source, setAt: new Date().toISOString() }, null, 2));
267
+ const envNote = process.env.AIM_DAILY_BUDGET_USD
268
+ ? "\nNote: AIM_DAILY_BUDGET_USD is set in your env and overrides this file."
269
+ : "";
270
+ return `Hard cap set: $${value.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")}. Saved to ${BUDGET_FILE}.${envNote}`;
271
+ }
272
+
273
+ export async function clearBudgetCap() {
274
+ await ensureStore();
275
+ if (existsSync(BUDGET_FILE)) await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: null, clearedAt: new Date().toISOString() }, null, 2));
276
+ return "Stored cap cleared. The gateway will only enforce AIM_DAILY_BUDGET_USD if set.";
277
+ }
278
+
279
+ export function currentBudgetCap() {
280
+ const cap = readBudget();
281
+ if (cap === null) return "No cap set. Run `runcap cap <usd>` or `runcap plan --apply-cap`.";
282
+ const src = process.env.AIM_DAILY_BUDGET_USD ? "env AIM_DAILY_BUDGET_USD" : `file ${BUDGET_FILE}`;
283
+ return `Current hard cap: $${cap.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")} (from ${src}).`;
284
+ }
285
+
286
+ export function hasStoredCap() {
287
+ return readStoredBudget() !== null;
288
+ }
289
+
210
290
  export async function listPlans() {
211
291
  await ensureStore();
212
292
  const plans = await readPlans();
@@ -244,9 +324,15 @@ export async function setupProject() {
244
324
  "OPENAI_API_KEY=",
245
325
  "AIM_UPSTREAM_BASE_URL=https://api.openai.com/v1",
246
326
  "",
247
- "# Optional budget guard. If estimated spend already exceeds this, gateway blocks new calls.",
327
+ "# Hard cap (USD) per budget window. The gateway prices each call from its",
328
+ "# own tokens and blocks it BEFORE forwarding if it would push spend over the cap.",
329
+ "# You can also set this with `runcap cap <usd>` or `runcap plan --apply-cap`.",
248
330
  "AIM_DAILY_BUDGET_USD=5",
249
331
  "",
332
+ "# Budget window: day (default, rolling 24h), session (since gateway start),",
333
+ "# all (never resets), or a number of hours. Caps reset per window.",
334
+ "AIM_BUDGET_WINDOW=day",
335
+ "",
250
336
  "# For demo mode without external API calls:",
251
337
  "AIM_GATEWAY_MODE=mock"
252
338
  ].join("\n");
@@ -292,6 +378,80 @@ export async function doctor() {
292
378
  ].join("\n");
293
379
  }
294
380
 
381
+ // Guided first-run, shown when `runcap` is invoked with no arguments. Explains
382
+ // in one screen what Runcap does, what it does NOT do, checks readiness, and
383
+ // gives exactly ONE next step based on the current state — so a newcomer reaches
384
+ // their first result without reading docs.
385
+ export async function welcome() {
386
+ await ensureStore();
387
+ const hasOpenAiKey = Boolean(process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY);
388
+ const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY);
389
+ const hasAnyKey = hasOpenAiKey || hasAnthropicKey;
390
+ const cap = readBudget();
391
+ const gateway = await readGatewaySummary({ windowMs: budgetWindowMs() });
392
+ const window = process.env.AIM_BUDGET_WINDOW ?? "day";
393
+
394
+ const tick = (ok) => (ok ? "[x]" : "[ ]");
395
+ const keyLabel = hasAnyKey
396
+ ? `API key detected (${[hasAnthropicKey && "Anthropic", hasOpenAiKey && "OpenAI"].filter(Boolean).join(" + ")})`
397
+ : "No API key in this shell (set ANTHROPIC_API_KEY or OPENAI_API_KEY)";
398
+ const capLabel = cap === null ? "No cap set yet" : `Cap set: $${cap.toFixed(2)} per ${window}`;
399
+
400
+ // One next step, chosen by what is missing.
401
+ let nextStep;
402
+ if (!hasAnyKey) {
403
+ nextStep = [
404
+ "Next: give Runcap the same provider key your agent already uses, e.g.",
405
+ " export ANTHROPIC_API_KEY=sk-... # or OPENAI_API_KEY=sk-...",
406
+ "Then run `runcap` again."
407
+ ];
408
+ } else if (cap === null) {
409
+ nextStep = [
410
+ "Next: set the most you want a run to spend, then run your agent through Runcap:",
411
+ " runcap cap 5",
412
+ " runcap run -- claude \"fix the failing test\"",
413
+ "Runcap starts a local gateway, points your agent at it, and blocks any call",
414
+ "that would push spend over $5, before it reaches the paid API.",
415
+ "",
416
+ "Not sure what to cap at? Estimate first:",
417
+ " runcap plan --apply-cap -- \"the task you're about to run\""
418
+ ];
419
+ } else {
420
+ nextStep = [
421
+ `You're ready. Cap is $${cap.toFixed(2)} per ${window}. Run any agent through Runcap:`,
422
+ " runcap run -- claude \"fix the failing test\"",
423
+ " runcap run -- codex \"...\" runcap run -- python my_agent.py",
424
+ "",
425
+ gateway.callCount > 0
426
+ ? `Spent so far this ${window}: ${fmtUsd(gateway.estimatedCostUsd)} across ${gateway.callCount} calls. See: runcap status`
427
+ : "No calls recorded yet. Your first `runcap run` will show the spend."
428
+ ];
429
+ }
430
+
431
+ return [
432
+ "Runcap: see and cap what your AI agent spends, before it spends it.",
433
+ "",
434
+ "What it does:",
435
+ " - Prices each call your agent makes from its own tokens.",
436
+ " - Blocks any call that would exceed your cap BEFORE it hits the paid API.",
437
+ " - Shows you the real spend, per run and per day.",
438
+ "",
439
+ "What it does NOT do (so there are no surprises):",
440
+ " - It does not give you an AI model. You bring your own provider API key.",
441
+ " - It does not run tasks for you. You bring your own agent (Claude Code,",
442
+ " Codex, a script: anything that calls OpenAI/Anthropic).",
443
+ " - It is a local tool for that setup, not a no-account web app.",
444
+ "",
445
+ "Readiness:",
446
+ ` ${tick(hasAnyKey)} ${keyLabel}`,
447
+ ` ${tick(cap !== null)} ${capLabel}`,
448
+ "",
449
+ ...nextStep,
450
+ "",
451
+ "Full command list: `runcap help`."
452
+ ].join("\n");
453
+ }
454
+
295
455
  export async function startDashboard({ port = 8791 } = {}) {
296
456
  await ensureStore();
297
457
  const server = http.createServer(async (request, response) => {
@@ -350,13 +510,15 @@ async function listenLocal(server, port, label) {
350
510
  });
351
511
  }
352
512
 
353
- export async function startGateway({ port = 8792, mock = false } = {}) {
354
- await ensureStore();
513
+ // Build (but do not start) the gateway HTTP server. Upstream targets are
514
+ // captured here from explicit args or env, so the auto-wrapper can pin the real
515
+ // upstream BEFORE it rewrites the child's base URLs to point at this gateway.
516
+ function createGatewayServer({ port = 8792, mock = false, upstream = {} } = {}) {
355
517
  const gatewayMode = mock || process.env.AIM_GATEWAY_MODE === "mock" ? "mock" : "proxy";
356
- const openaiKey = process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
357
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
358
- const openaiBaseUrl = process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
359
- const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
518
+ const openaiKey = upstream.openaiKey ?? process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
519
+ const anthropicKey = upstream.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
520
+ const openaiBaseUrl = upstream.openaiBaseUrl ?? process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
521
+ const anthropicBaseUrl = upstream.anthropicBaseUrl ?? process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
360
522
  const anthropicVersion = process.env.ANTHROPIC_VERSION ?? "2023-06-01";
361
523
  if (gatewayMode !== "mock" && !openaiKey && !anthropicKey) {
362
524
  throw new Error("Missing upstream key. Set OPENAI_API_KEY (for /v1/chat/completions) and/or ANTHROPIC_API_KEY (for /v1/messages). The gateway cannot proxy without at least one.");
@@ -384,8 +546,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
384
546
  const bodyText = await readRequestBody(request);
385
547
  const requestBody = safeJson(bodyText) ?? {};
386
548
  const budget = readBudget();
387
- const summary = await readGatewaySummary();
388
- if (budget !== null && summary.estimatedCostUsd >= budget) {
549
+ const summary = await readGatewaySummary({ windowMs: budgetWindowMs() });
550
+ // Compress the request body once (safe, lossless-by-construction). Disable with AIM_COMPRESS=off.
551
+ const compressionOn = (process.env.AIM_COMPRESS ?? "on").toLowerCase() !== "off";
552
+ let forwardBody = bodyText;
553
+ let compression = null;
554
+ if (compressionOn) {
555
+ const c = compressRequestBody(requestBody);
556
+ if (c.savedChars > 0 && c.touched > 0) {
557
+ forwardBody = JSON.stringify(c.body);
558
+ compression = {
559
+ savedTokens: c.savedTokens,
560
+ savedChars: c.savedChars,
561
+ beforeChars: c.before,
562
+ afterChars: c.after,
563
+ fieldsTouched: c.touched,
564
+ truth: "estimated"
565
+ };
566
+ }
567
+ }
568
+ // Pre-call cap: price THIS request from its own tokens and block before
569
+ // forwarding if (already spent in the window + this call) would exceed the
570
+ // cap. Catches both accumulated overspend and a single oversized call.
571
+ const preCall = estimateRequestCost(requestBody);
572
+ const callEstimate = preCall.estimatedUsd ?? 0;
573
+ const projectedCostUsd = Number((summary.estimatedCostUsd + callEstimate).toFixed(6));
574
+ if (budget !== null && projectedCostUsd > budget) {
575
+ const blockedByThisCall = summary.estimatedCostUsd < budget;
389
576
  const event = {
390
577
  at: new Date().toISOString(),
391
578
  path: url.pathname,
@@ -395,11 +582,39 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
395
582
  usage: null,
396
583
  cost: null,
397
584
  truth: "budget_guard",
398
- error: `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
585
+ guard: {
586
+ spentUsd: summary.estimatedCostUsd,
587
+ callEstimateUsd: callEstimate,
588
+ callEstimateTruth: preCall.truth,
589
+ projectedUsd: projectedCostUsd,
590
+ capUsd: budget,
591
+ blockedByThisCall
592
+ },
593
+ error: blockedByThisCall
594
+ ? `Budget would be exceeded by this call: $${summary.estimatedCostUsd} spent + ~$${callEstimate} this call > cap $${budget}`
595
+ : `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
399
596
  requestHash: createHash("sha1").update(bodyText).digest("hex")
400
597
  };
401
598
  await appendGatewayEvent(event);
402
- sendJson(response, { error: event.error, truth: event.truth }, 429);
599
+ sendJson(response, { error: event.error, truth: event.truth, guard: event.guard }, 429);
600
+ const breachText = blockedByThisCall
601
+ ? `Runcap: cap protected. Blocked a ~$${callEstimate} call on ${event.model} before it ran ($${summary.estimatedCostUsd} already spent, cap $${budget}).`
602
+ : `Runcap: cap hit. Run blocked at $${summary.estimatedCostUsd} (cap $${budget}) on ${event.model}. The gateway stopped the call before it could spend more.`;
603
+ sendAlert(breachText)
604
+ .then((channels) => {
605
+ if (channels && channels.length) console.log(`Cap-breach alert sent to: ${channels.join(", ")}`);
606
+ })
607
+ .catch(() => {});
608
+ syncRun({
609
+ mission_id: null,
610
+ label: `gateway cap breach (${event.model})`,
611
+ estimate_low: budget,
612
+ estimate_high: projectedCostUsd,
613
+ cap: budget,
614
+ actual: summary.estimatedCostUsd,
615
+ capped: true,
616
+ status: "capped"
617
+ }).catch(() => {});
403
618
  return;
404
619
  }
405
620
  if (gatewayMode === "mock") {
@@ -414,6 +629,7 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
414
629
  durationMs: Date.now() - started,
415
630
  usage: responseBody.usage,
416
631
  cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
632
+ compression,
417
633
  truth: "mock_provider_usage",
418
634
  requestHash: createHash("sha1").update(bodyText).digest("hex")
419
635
  });
@@ -437,13 +653,16 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
437
653
  "authorization": `Bearer ${upstreamKey}`,
438
654
  "content-type": request.headers["content-type"] ?? "application/json"
439
655
  };
440
- // Anthropic base URLs already include /v1; avoid doubling it.
441
- const pathForUpstream = isAnthropic ? url.pathname.replace(/^\/v1/, "") : url.pathname;
656
+ // Both default upstream base URLs already include /v1, and the child calls
657
+ // us at /v1/*. Strip the leading /v1 from the path when the upstream base
658
+ // already ends in /v1, so we never produce a doubled /v1/v1 (OpenAI 404).
659
+ const baseHasV1 = /\/v1\/?$/.test(upstreamBase);
660
+ const pathForUpstream = baseHasV1 ? url.pathname.replace(/^\/v1/, "") : url.pathname;
442
661
  const upstreamUrl = `${upstreamBase.replace(/\/$/, "")}${pathForUpstream}`;
443
662
  const upstreamResponse = await fetch(upstreamUrl, {
444
663
  method: "POST",
445
664
  headers,
446
- body: bodyText
665
+ body: forwardBody
447
666
  });
448
667
  const responseText = await upstreamResponse.text();
449
668
  response.writeHead(upstreamResponse.status, {
@@ -461,9 +680,23 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
461
680
  durationMs: Date.now() - started,
462
681
  usage: responseBody.usage ?? null,
463
682
  cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
683
+ compression,
464
684
  truth: responseBody.usage ? "provider_usage" : "unknown",
465
685
  requestHash: createHash("sha1").update(bodyText).digest("hex")
466
686
  });
687
+ if (responseBody.usage) {
688
+ const spent = await readGatewaySummary({ windowMs: budgetWindowMs() });
689
+ syncRun({
690
+ mission_id: null,
691
+ label: "gateway session (actual spend)",
692
+ estimate_low: spent.estimatedCostUsd,
693
+ estimate_high: spent.estimatedCostUsd,
694
+ cap: budget,
695
+ actual: spent.estimatedCostUsd,
696
+ capped: false,
697
+ status: "running"
698
+ }).catch(() => {});
699
+ }
467
700
  } catch (error) {
468
701
  await appendGatewayEvent({
469
702
  at: new Date().toISOString(),
@@ -479,6 +712,13 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
479
712
  sendJson(response, { error: error.message }, 500);
480
713
  }
481
714
  });
715
+ return { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl };
716
+ }
717
+
718
+ export async function startGateway({ port = 8792, mock = false } = {}) {
719
+ await ensureStore();
720
+ const { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl } =
721
+ createGatewayServer({ port, mock });
482
722
  await listenLocal(server, port, "gateway");
483
723
  console.log(`Runcap gateway: http://127.0.0.1:${port}/v1`);
484
724
  console.log(`Mode: ${gatewayMode}`);
@@ -491,6 +731,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
491
731
  console.log("Press Ctrl+C to stop.");
492
732
  }
493
733
 
734
+ // Start the gateway on an ephemeral free port for the duration of one wrapped
735
+ // run, returning a handle the wrapper uses to point the child at it and to shut
736
+ // it down afterward. Upstream is pinned from the CURRENT env before the child's
737
+ // base URLs are rewritten, so the gateway proxies to the real provider, not to
738
+ // itself.
739
+ async function startEphemeralGateway({ mock = false } = {}) {
740
+ await ensureStore();
741
+ const upstream = {
742
+ openaiKey: process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY,
743
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
744
+ openaiBaseUrl: process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
745
+ anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
746
+ };
747
+ const { server, gatewayMode } = createGatewayServer({ port: 0, mock, upstream });
748
+ await new Promise((resolve, reject) => {
749
+ server.once("error", reject);
750
+ server.listen(0, "127.0.0.1", resolve);
751
+ });
752
+ const actualPort = server.address().port;
753
+ return {
754
+ port: actualPort,
755
+ baseUrl: `http://127.0.0.1:${actualPort}`,
756
+ gatewayMode,
757
+ close: () => new Promise((resolve) => server.close(resolve))
758
+ };
759
+ }
760
+
494
761
  export async function showStatus(options = {}) {
495
762
  await ensureStore();
496
763
  const fuel = await readFuel();
@@ -588,7 +855,9 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
588
855
  ].filter(Boolean).length;
589
856
  const hasRepo = Boolean(snapshot.packageJson);
590
857
  const hasVerification = hasRepo && Object.keys(snapshot.packageJson?.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name));
591
- const fuel = Number.isFinite(Number(fuelPercent)) ? Number(fuelPercent) : null;
858
+ const fuel = fuelPercent === null || fuelPercent === undefined || fuelPercent === "" || !Number.isFinite(Number(fuelPercent))
859
+ ? null
860
+ : Number(fuelPercent);
592
861
  const budgetRisk = bigSignals > 0 || (fuel !== null && fuel < 30) ? "High" : fuel !== null && fuel < 55 ? "Medium" : "Low";
593
862
  const expectedWasteReduction = budgetRisk === "High" ? "40-70%" : budgetRisk === "Medium" ? "25-45%" : "10-25%";
594
863
  const qualityRisk = quality === "cheap" && budgetRisk === "High" ? "High" : budgetRisk === "High" ? "Medium" : "Low";
@@ -749,13 +1018,13 @@ function commandTemplatesForPlan(goal, missions) {
749
1018
  }));
750
1019
  }
751
1020
 
752
- async function runChild(command, cwd) {
1021
+ async function runChild(command, cwd, extraEnv = {}) {
753
1022
  const started = Date.now();
754
1023
  const [program, ...args] = command;
755
1024
  return await new Promise((resolve) => {
756
1025
  const child = spawn(program, args, {
757
1026
  cwd,
758
- env: { ...process.env, AIM_WRAPPED: "1" },
1027
+ env: { ...process.env, AIM_WRAPPED: "1", ...extraEnv },
759
1028
  shell: false
760
1029
  });
761
1030
  let stdout = "";
@@ -1099,6 +1368,7 @@ async function dashboardStatus() {
1099
1368
  return {
1100
1369
  fuel,
1101
1370
  gateway,
1371
+ budget: readBudget(),
1102
1372
  missionCount: missions.length,
1103
1373
  latest: missions[0] ?? null,
1104
1374
  counts: missions.reduce((acc, mission) => {
@@ -1118,26 +1388,83 @@ async function readGatewayEvents() {
1118
1388
  return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
1119
1389
  }
1120
1390
 
1121
- async function readGatewaySummary() {
1122
- const events = await readGatewayEvents();
1391
+ async function readGatewaySummary({ windowMs } = {}) {
1392
+ const allEvents = await readGatewayEvents();
1393
+ // When a window is given (used by the budget guard), only count spend whose
1394
+ // timestamp falls inside it. The cap is then a per-window budget that resets,
1395
+ // not an all-time counter that locks the gateway forever.
1396
+ const events = windowMs
1397
+ ? allEvents.filter((event) => {
1398
+ const t = Date.parse(event.at ?? "");
1399
+ return Number.isFinite(t) && Date.now() - t <= windowMs;
1400
+ })
1401
+ : allEvents;
1123
1402
  const successful = events.filter((event) => event.status >= 200 && event.status < 300);
1124
- const totalTokens = events.reduce((sum, event) => sum + Number(event.usage?.total_tokens ?? 0), 0);
1403
+ const totalTokens = events.reduce((sum, event) => {
1404
+ const u = event.usage;
1405
+ if (!u) return sum;
1406
+ const total = Number(u.total_tokens ?? 0) ||
1407
+ Number(u.prompt_tokens ?? u.input_tokens ?? 0) + Number(u.completion_tokens ?? u.output_tokens ?? 0);
1408
+ return sum + total;
1409
+ }, 0);
1125
1410
  const estimatedCost = events.reduce((sum, event) => sum + Number(event.cost?.estimatedUsd ?? 0), 0);
1411
+ const savedTokens = events.reduce((sum, event) => sum + Number(event.compression?.savedTokens ?? 0), 0);
1412
+ // Value the saved tokens at a blended input rate from the price table so we can
1413
+ // show one honest dollar figure. Per saved input token: use the model's input rate.
1414
+ const savedUsd = events.reduce((sum, event) => {
1415
+ const saved = Number(event.compression?.savedTokens ?? 0);
1416
+ if (!saved) return sum;
1417
+ const pricing = modelPricing(event.model);
1418
+ const inputRate = pricing ? pricing.inputPerMillion : 3; // fall back to a mid Sonnet-ish rate
1419
+ return sum + (saved * inputRate) / 1_000_000;
1420
+ }, 0);
1126
1421
  return {
1127
1422
  callCount: events.length,
1128
1423
  successfulCallCount: successful.length,
1129
1424
  totalTokens,
1130
1425
  estimatedCostUsd: Number(estimatedCost.toFixed(6)),
1426
+ savedTokens,
1427
+ savedUsd: Number(savedUsd.toFixed(6)),
1428
+ wouldHaveSpentUsd: Number((estimatedCost + savedUsd).toFixed(6)),
1131
1429
  truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
1132
1430
  ? "usage_plus_static_price_table"
1133
1431
  : "unknown",
1432
+ windowMs: windowMs ?? null,
1134
1433
  recent: events.slice(-20).reverse()
1135
1434
  };
1136
1435
  }
1137
1436
 
1437
+ // How wide the budget window is, in ms. AIM_BUDGET_WINDOW controls it:
1438
+ // "day" (default) → rolling 24h, "session" → since gateway start, "all" → no reset.
1439
+ const GATEWAY_STARTED_AT = Date.now();
1440
+ function budgetWindowMs() {
1441
+ const mode = (process.env.AIM_BUDGET_WINDOW ?? "day").toLowerCase();
1442
+ if (mode === "all") return undefined;
1443
+ if (mode === "session") return Date.now() - GATEWAY_STARTED_AT;
1444
+ const hours = Number(mode);
1445
+ if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000;
1446
+ return 24 * 60 * 60 * 1000; // "day" default
1447
+ }
1448
+
1449
+ // The cap value. Precedence: AIM_DAILY_BUDGET_USD env > persisted budget.json
1450
+ // (written by `runcap plan` / `runcap cap`). Null means no cap is set.
1138
1451
  function readBudget() {
1139
1452
  const raw = process.env.AIM_DAILY_BUDGET_USD;
1140
- if (raw === undefined || raw === "") return null;
1453
+ if (raw !== undefined && raw !== "") {
1454
+ const value = Number(raw);
1455
+ if (Number.isFinite(value) && value >= 0) return value;
1456
+ }
1457
+ const stored = readStoredBudget();
1458
+ return stored;
1459
+ }
1460
+
1461
+ function readStoredBudget() {
1462
+ if (!existsSync(BUDGET_FILE)) return null;
1463
+ let text = null;
1464
+ try { text = readFileSync(BUDGET_FILE, "utf8"); } catch { return null; }
1465
+ const parsed = safeJson(text);
1466
+ const raw = parsed?.capUsd;
1467
+ if (raw === null || raw === undefined || raw === "") return null;
1141
1468
  const value = Number(raw);
1142
1469
  return Number.isFinite(value) && value >= 0 ? value : null;
1143
1470
  }
@@ -1254,6 +1581,43 @@ function estimateApiCost(usage, model) {
1254
1581
  };
1255
1582
  }
1256
1583
 
1584
+ // Estimate the cost of a request BEFORE it is forwarded upstream, from the
1585
+ // request body alone. Input tokens are estimated from the serialized prompt;
1586
+ // output tokens from the caller's max_tokens (the worst case the provider can
1587
+ // bill). Returns null when the model has no verified price, so the guard can
1588
+ // decide whether to fail open or closed rather than guessing a number.
1589
+ function estimateRequestCost(requestBody) {
1590
+ const model = requestBody?.model ?? "";
1591
+ const pricing = modelPricing(model);
1592
+ if (!pricing) return { estimatedUsd: null, truth: "unknown_price", model };
1593
+
1594
+ const promptText = JSON.stringify(
1595
+ requestBody.messages ?? requestBody.system ?? requestBody.input ?? requestBody.prompt ?? ""
1596
+ );
1597
+ const inputTokens = estimateTokens(promptText);
1598
+ // Worst-case output the provider could bill: honor the caller's stated cap,
1599
+ // else assume a generous default so the guard is not fooled by an open-ended call.
1600
+ const maxOutput = Number(
1601
+ requestBody.max_tokens ??
1602
+ requestBody.max_completion_tokens ??
1603
+ requestBody.max_output_tokens ??
1604
+ 4096
1605
+ );
1606
+ const outputTokens = Number.isFinite(maxOutput) && maxOutput > 0 ? maxOutput : 4096;
1607
+
1608
+ const estimatedUsd =
1609
+ (inputTokens / 1_000_000) * pricing.inputPerMillion +
1610
+ (outputTokens / 1_000_000) * pricing.outputPerMillion;
1611
+
1612
+ return {
1613
+ estimatedUsd: Number(estimatedUsd.toFixed(6)),
1614
+ truth: "pre_call_estimate_from_request",
1615
+ model,
1616
+ inputTokens,
1617
+ outputTokens
1618
+ };
1619
+ }
1620
+
1257
1621
  function modelPricing(model = "") {
1258
1622
  const name = String(model).toLowerCase();
1259
1623
  const batch = name.includes("batch");
@@ -1528,78 +1892,96 @@ function renderDashboardHtml() {
1528
1892
  <meta charset="utf-8">
1529
1893
  <meta name="viewport" content="width=device-width, initial-scale=1">
1530
1894
  <title>Runcap</title>
1895
+ <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
1896
+ <link href="https://api.fontshare.com/v2/css?f[]=clash-display@600,700&f[]=general-sans@400,500,600,700&f[]=jetbrains-mono@400,500&display=swap" rel="stylesheet">
1531
1897
  <style>
1532
- :root { color-scheme: dark; --bg:#080d13; --panel:#111821; --panel2:#17212d; --soft:#202b38; --line:#2c3948; --text:#f8fafc; --muted:#a8b3c2; --good:#52d789; --warn:#ffd166; --bad:#ff6868; --accent:#63d5ff; --violet:#b8a0ff; }
1898
+ :root { color-scheme: light; --bg:#f6f7f9; --panel:#ffffff; --panel2:#fbfbfc; --soft:#f0f2f5; --line:#e6e8ec; --text:#0b0d12; --muted:#6b7280; --good:#0d9f6e; --warn:#b7791f; --bad:#dc2626; --accent:#4f46e5; --violet:#7c3aed; --shadow:0 1px 2px rgba(16,24,40,0.04), 0 8px 24px rgba(16,24,40,0.06); }
1533
1899
  * { box-sizing: border-box; }
1534
- body { margin:0; min-height:100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
1535
- body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at 20% 0%, rgba(99,213,255,0.12), transparent 32%), radial-gradient(circle at 90% 8%, rgba(184,160,255,0.1), transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.03), transparent 260px); }
1900
+ body { margin:0; min-height:100vh; font-family: "General Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
1901
+ body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at 18% -4%, rgba(79,70,229,0.06), transparent 36%), radial-gradient(circle at 92% 4%, rgba(124,58,237,0.05), transparent 38%); }
1536
1902
  button, textarea, select, input { font:inherit; }
1537
1903
  .app { position:relative; display:grid; grid-template-columns: 320px minmax(0,1fr); min-height:100vh; }
1538
- aside { border-right:1px solid var(--line); background:rgba(8,13,19,0.82); padding:22px; overflow:auto; }
1539
- main { padding:28px; overflow:auto; }
1540
- h1 { margin:0; font-size:24px; letter-spacing:0; }
1541
- h2 { margin:0; font-size:38px; line-height:1.06; letter-spacing:0; }
1542
- h3 { margin:0; font-size:15px; }
1904
+ aside { border-right:1px solid var(--line); background:var(--panel); padding:22px; overflow:auto; }
1905
+ main { padding:32px 36px; overflow:auto; }
1906
+ h1 { margin:0; font-family:"Clash Display", sans-serif; font-weight:700; font-size:23px; letter-spacing:-0.01em; }
1907
+ h2 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:34px; line-height:1.08; letter-spacing:-0.02em; }
1908
+ h3 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:15px; }
1543
1909
  p { margin:0; }
1544
1910
  .muted { color:var(--muted); }
1545
1911
  .brand { display:flex; align-items:center; gap:12px; margin-bottom:22px; }
1546
- .mark { width:42px; height:42px; border-radius:8px; display:grid; place-items:center; color:#061017; font-weight:900; background:linear-gradient(135deg, var(--accent), var(--good)); }
1547
- .tagline { color:var(--muted); font-size:13px; margin-top:4px; line-height:1.35; }
1912
+ .mark { width:40px; height:40px; border-radius:11px; display:grid; place-items:center; color:#fff; font-family:"Clash Display",sans-serif; font-weight:700; background:linear-gradient(135deg, var(--accent), var(--violet)); box-shadow:var(--shadow); }
1913
+ .tagline { color:var(--muted); font-size:13px; margin-top:3px; line-height:1.35; }
1548
1914
  .nav { display:grid; gap:8px; margin:18px 0 22px; }
1549
- .nav button { text-align:left; border:1px solid var(--line); background:rgba(17,24,33,0.82); color:var(--text); border-radius:8px; padding:12px; cursor:pointer; }
1550
- .nav button.active, .nav button:hover { border-color:var(--accent); background:#152434; }
1551
- .nav strong { display:block; }
1915
+ .nav button { text-align:left; border:1px solid var(--line); background:var(--panel); color:var(--text); border-radius:11px; padding:12px 14px; cursor:pointer; transition:all .15s; }
1916
+ .nav button.active, .nav button:hover { border-color:var(--accent); background:#f5f4ff; }
1917
+ .nav strong { display:block; font-weight:600; }
1552
1918
  .nav span { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
1553
- .side-title { margin:18px 0 10px; color:var(--muted); font-size:12px; font-weight:800; text-transform:uppercase; }
1919
+ .side-title { margin:18px 0 10px; color:var(--muted); font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; }
1554
1920
  .summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
1555
- .mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:rgba(17,24,33,0.9); border-radius:8px; }
1556
- .mini { padding:12px; min-height:76px; }
1557
- .mini strong { display:block; font-size:22px; }
1921
+ .mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:var(--panel); border-radius:14px; }
1922
+ .mini { padding:13px; min-height:76px; box-shadow:var(--shadow); }
1923
+ .mini strong { display:block; font-family:"JetBrains Mono",monospace; font-size:22px; font-weight:500; }
1558
1924
  .mini span { color:var(--muted); font-size:12px; }
1559
- .mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:12px; }
1925
+ .mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:13px; transition:all .15s; }
1560
1926
  .mission:hover, .mission.active { border-color:var(--accent); }
1561
- .mission.active { background:#142232; box-shadow: inset 3px 0 0 var(--accent); }
1927
+ .mission.active { background:#f5f4ff; box-shadow: inset 3px 0 0 var(--accent); }
1562
1928
  .mission-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; }
1563
- .mission-name { font-weight:800; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1929
+ .mission-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1564
1930
  .mission-line { color:var(--muted); font-size:13px; line-height:1.35; }
1565
- .status { font-size:12px; border:1px solid var(--line); padding:4px 8px; border-radius:999px; white-space:nowrap; }
1566
- .stuck { color:var(--bad); border-color:rgba(255,104,104,0.5); }
1567
- .at_risk { color:var(--warn); border-color:rgba(255,209,102,0.5); }
1568
- .progressing { color:var(--good); border-color:rgba(82,215,137,0.5); }
1931
+ .status { font-size:12px; border:1px solid var(--line); padding:4px 9px; border-radius:999px; white-space:nowrap; font-weight:500; }
1932
+ .stuck { color:var(--bad); border-color:rgba(220,38,38,0.35); background:rgba(220,38,38,0.05); }
1933
+ .at_risk { color:var(--warn); border-color:rgba(183,121,31,0.35); background:rgba(183,121,31,0.05); }
1934
+ .progressing { color:var(--good); border-color:rgba(13,159,110,0.35); background:rgba(13,159,110,0.05); }
1569
1935
  .hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(360px,0.8fr); gap:18px; margin-bottom:18px; }
1570
- .panel { padding:24px; }
1571
- .hero-copy { color:var(--muted); font-size:17px; line-height:1.55; margin-top:14px; max-width:880px; }
1936
+ .panel { padding:26px; box-shadow:var(--shadow); }
1937
+ .hero-copy { color:var(--muted); font-size:16px; line-height:1.55; margin-top:14px; max-width:880px; }
1938
+ /* SAVINGS HERO — the one visible number (Kirill's core fix) */
1939
+ .savings { grid-column:1 / -1; border:1px solid var(--line); border-radius:18px; padding:28px 30px; margin-bottom:18px; background:linear-gradient(135deg,#ffffff, #f7f6ff); box-shadow:var(--shadow); }
1940
+ .savings-label { font-size:12px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--muted); }
1941
+ .savings-row { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; margin-top:8px; }
1942
+ .savings-big { font-family:"Clash Display",sans-serif; font-weight:700; font-size:clamp(40px,6vw,68px); line-height:1; letter-spacing:-0.03em; background:linear-gradient(135deg,var(--accent),var(--violet)); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
1943
+ .savings-unit { font-family:"JetBrains Mono",monospace; font-size:17px; color:var(--muted); padding-bottom:8px; }
1944
+ .savings-sub { color:var(--muted); font-size:15px; margin-top:12px; }
1945
+ .savings-sub b { color:var(--text); font-family:"JetBrains Mono",monospace; font-weight:500; }
1946
+ .capbar { margin-top:18px; }
1947
+ .capbar-track { height:12px; border-radius:999px; background:var(--soft); overflow:hidden; border:1px solid var(--line); }
1948
+ .capbar-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--good),var(--accent)); transition:width .4s; }
1949
+ .capbar-fill.warn { background:linear-gradient(90deg,var(--warn),#e8590c); }
1950
+ .capbar-fill.over { background:linear-gradient(90deg,var(--bad),#991b1b); }
1951
+ .capbar-meta { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-top:7px; font-family:"JetBrains Mono",monospace; }
1572
1952
  .badge-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
1573
- .badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px 10px; font-size:12px; }
1574
- .badge.good { color:var(--good); border-color:rgba(82,215,137,0.48); }
1575
- .badge.warn { color:var(--warn); border-color:rgba(255,209,102,0.48); }
1576
- .badge.bad { color:var(--bad); border-color:rgba(255,104,104,0.48); }
1953
+ .badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px 11px; font-size:12px; background:var(--panel2); }
1954
+ .badge.good { color:var(--good); border-color:rgba(13,159,110,0.35); }
1955
+ .badge.warn { color:var(--warn); border-color:rgba(183,121,31,0.35); }
1956
+ .badge.bad { color:var(--bad); border-color:rgba(220,38,38,0.35); }
1577
1957
  .metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:20px; }
1578
- .metric { padding:14px; }
1579
- .metric strong { display:block; font-size:24px; line-height:1.1; }
1958
+ .metric { padding:15px; box-shadow:var(--shadow); }
1959
+ .metric strong { display:block; font-family:"JetBrains Mono",monospace; font-size:23px; font-weight:500; line-height:1.1; }
1580
1960
  .metric span { display:block; color:var(--muted); font-size:12px; margin-top:6px; }
1581
- .planner textarea { width:100%; min-height:128px; resize:vertical; background:#0a1017; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:13px; line-height:1.45; }
1961
+ .planner textarea { width:100%; min-height:128px; resize:vertical; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:13px; line-height:1.45; }
1582
1962
  .field-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
1583
- select, input { width:100%; background:#0a1017; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:10px; }
1584
- label { display:block; color:var(--muted); font-size:12px; font-weight:750; margin:0 0 7px; }
1585
- .primary, .ghost { border-radius:8px; padding:10px 13px; cursor:pointer; font-weight:800; }
1586
- .primary { border:1px solid rgba(99,213,255,0.7); color:#061017; background:linear-gradient(135deg, var(--accent), var(--good)); }
1587
- .ghost { border:1px solid var(--line); color:var(--text); background:#0a1017; }
1963
+ select, input { width:100%; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:11px; }
1964
+ label { display:block; color:var(--muted); font-size:12px; font-weight:600; margin:0 0 7px; }
1965
+ .primary, .ghost { border-radius:11px; padding:11px 16px; cursor:pointer; font-weight:600; transition:all .15s; }
1966
+ .primary { border:1px solid transparent; color:#fff; background:linear-gradient(135deg, var(--accent), var(--violet)); box-shadow:0 6px 16px rgba(79,70,229,0.25); }
1967
+ .primary:hover { filter:brightness(1.06); transform:translateY(-1px); }
1968
+ .ghost { border:1px solid var(--line); color:var(--text); background:var(--panel); }
1969
+ .ghost:hover { border-color:var(--accent); }
1588
1970
  .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
1589
1971
  .plan-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }
1590
- .plan-card { padding:16px; }
1591
- .plan-card strong { display:block; margin-bottom:8px; }
1972
+ .plan-card { padding:18px; box-shadow:var(--shadow); }
1973
+ .plan-card strong { display:block; margin-bottom:8px; font-weight:600; }
1592
1974
  .plan-card p, .step p { color:var(--muted); line-height:1.48; }
1593
1975
  .timeline { display:grid; gap:10px; margin-top:14px; }
1594
- .step { padding:15px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; }
1595
- .num { width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:#0a1017; border:1px solid var(--line); color:var(--accent); font-weight:900; }
1596
- .rescue { border-color:rgba(99,213,255,0.55); background:#172433; }
1597
- .decision { color:var(--warn); font-weight:900; font-size:18px; margin:8px 0 0; }
1598
- pre { white-space:pre-wrap; margin:12px 0 0; background:#070b10; border:1px solid var(--line); border-radius:8px; padding:13px; line-height:1.5; overflow:auto; }
1599
- details { padding:14px 16px; margin-top:14px; }
1600
- summary { cursor:pointer; color:var(--muted); font-weight:800; }
1976
+ .step { padding:16px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; box-shadow:var(--shadow); }
1977
+ .num { width:28px; height:28px; border-radius:9px; display:grid; place-items:center; background:#f5f4ff; border:1px solid var(--line); color:var(--accent); font-family:"JetBrains Mono",monospace; font-weight:500; }
1978
+ .rescue { border-color:rgba(79,70,229,0.4); background:linear-gradient(135deg,#ffffff,#f7f6ff); }
1979
+ .decision { color:var(--bad); font-weight:600; font-size:18px; margin:8px 0 0; }
1980
+ pre { white-space:pre-wrap; margin:12px 0 0; background:#0b0d12; color:#e6e8ec; border:1px solid var(--line); border-radius:11px; padding:14px; line-height:1.5; overflow:auto; font-family:"JetBrains Mono",monospace; font-size:13px; }
1981
+ details { padding:14px 18px; margin-top:14px; box-shadow:var(--shadow); }
1982
+ summary { cursor:pointer; color:var(--muted); font-weight:600; }
1601
1983
  .hidden { display:none; }
1602
- .empty { padding:40px; text-align:left; }
1984
+ .empty { padding:42px; text-align:left; }
1603
1985
  .copy { margin-top:10px; }
1604
1986
  @media (max-width: 1180px) { .app { grid-template-columns:1fr; } aside { border-right:0; border-bottom:1px solid var(--line); } .hero, .plan-grid, .metrics { grid-template-columns:1fr; } .field-row { grid-template-columns:1fr; } }
1605
1987
  </style>
@@ -1608,10 +1990,10 @@ function renderDashboardHtml() {
1608
1990
  <div class="app">
1609
1991
  <aside>
1610
1992
  <div class="brand">
1611
- <div class="mark">AI</div>
1993
+ <div class="mark">R</div>
1612
1994
  <div>
1613
1995
  <h1>Runcap</h1>
1614
- <div class="tagline">Plan AI work. Route models. Prove progress. Stop waste.</div>
1996
+ <div class="tagline">Estimate cost. Cap spend. Compress tokens. Rescue stuck runs.</div>
1615
1997
  </div>
1616
1998
  </div>
1617
1999
  <div class="nav">
@@ -1624,10 +2006,10 @@ function renderDashboardHtml() {
1624
2006
  <div class="mission-line" id="truth">Gateway truth: loading...</div>
1625
2007
  </div>
1626
2008
  <div class="summary">
1627
- <div class="mini"><strong id="total">0</strong><span>checks</span></div>
1628
- <div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
2009
+ <div class="mini"><strong id="cost">$0</strong><span>spent so far</span></div>
2010
+ <div class="mini"><strong id="saved" style="color:var(--good)">$0</strong><span>saved by compression</span></div>
1629
2011
  <div class="mini"><strong id="tokens">0</strong><span>API tokens</span></div>
1630
- <div class="mini"><strong id="cost">$0</strong><span>API estimate</span></div>
2012
+ <div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
1631
2013
  </div>
1632
2014
  <div class="side-title">Saved plans</div>
1633
2015
  <div id="plans"></div>
@@ -1652,13 +2034,16 @@ function renderDashboardHtml() {
1652
2034
  state.plans = plans;
1653
2035
  document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
1654
2036
  document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
1655
- document.getElementById("total").textContent = status.missionCount;
1656
2037
  document.getElementById("needs").textContent = (status.counts.stuck ?? 0) + (status.counts.at_risk ?? 0);
1657
- document.getElementById("tokens").textContent = status.gateway.totalTokens;
1658
- document.getElementById("cost").textContent = "$" + status.gateway.estimatedCostUsd;
2038
+ document.getElementById("tokens").textContent = Number(status.gateway.totalTokens || 0).toLocaleString();
2039
+ document.getElementById("cost").textContent = "$" + (status.gateway.estimatedCostUsd ?? 0);
2040
+ document.getElementById("saved").textContent = "$" + (status.gateway.savedUsd ?? 0);
2041
+ state.gateway = status.gateway;
2042
+ state.budget = status.budget;
1659
2043
  renderList();
1660
2044
  renderPlans();
1661
2045
  if (!state.plannerRendered) renderPlanner(status);
2046
+ renderSavingsHero(status.gateway);
1662
2047
  if (!state.selected && missions[0]) showMission(missions[0].id, false);
1663
2048
  if (!missions[0]) renderEmptyMonitor();
1664
2049
  }
@@ -1687,10 +2072,40 @@ function renderDashboardHtml() {
1687
2072
  '</button>'
1688
2073
  ).join("");
1689
2074
  }
2075
+ function renderSavingsHero(g) {
2076
+ const el = document.getElementById("savings-hero");
2077
+ if (!el || !g) return;
2078
+ const saved = Number(g.savedUsd ?? 0);
2079
+ const tokens = Number(g.savedTokens ?? 0);
2080
+ const spent = Number(g.estimatedCostUsd ?? 0);
2081
+ const wouldHave = Number(g.wouldHaveSpentUsd ?? spent);
2082
+ const fmt = (n) => "$" + (n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2));
2083
+ if (tokens === 0 && spent === 0) {
2084
+ el.innerHTML = '<div class="savings"><div class="savings-label">Your savings will show here</div>' +
2085
+ '<div class="savings-row"><div class="savings-big">$0.00</div><div class="savings-unit">saved so far</div></div>' +
2086
+ '<div class="savings-sub">Point your agent at the Runcap gateway and every call is compressed and capped. This number grows on its own.</div></div>';
2087
+ return;
2088
+ }
2089
+ // cap bar
2090
+ let capHtml = '';
2091
+ if (state.budget && state.budget > 0) {
2092
+ const pct = Math.min(100, (spent / state.budget) * 100);
2093
+ const cls = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : '';
2094
+ capHtml = '<div class="capbar"><div class="capbar-track"><div class="capbar-fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div></div>' +
2095
+ '<div class="capbar-meta"><span>spent ' + fmt(spent) + '</span><span>cap ' + fmt(state.budget) + '</span></div></div>';
2096
+ }
2097
+ el.innerHTML = '<div class="savings">' +
2098
+ '<div class="savings-label">You saved</div>' +
2099
+ '<div class="savings-row"><div class="savings-big">' + fmt(saved) + '</div><div class="savings-unit">' + tokens.toLocaleString() + ' tokens compressed away</div></div>' +
2100
+ '<div class="savings-sub">You would have spent <b>' + fmt(wouldHave) + '</b>, Runcap compressed it down to <b>' + fmt(spent) + '</b>. Same answers, fewer tokens.</div>' +
2101
+ capHtml +
2102
+ '</div>';
2103
+ }
1690
2104
  function renderPlanner(status) {
1691
2105
  state.plannerRendered = true;
1692
2106
  const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
1693
2107
  document.getElementById("plan-view").innerHTML =
2108
+ '<div id="savings-hero"></div>' +
1694
2109
  '<div class="hero">' +
1695
2110
  '<div class="panel">' +
1696
2111
  '<h2>Turn one expensive AI request into a managed plan.</h2>' +