runcap 0.1.1 → 0.2.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.
@@ -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,38 @@ 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
+ export async function setBudgetCap(capUsd, { source = "manual" } = {}) {
251
+ await ensureStore();
252
+ const value = Number(capUsd);
253
+ if (!Number.isFinite(value) || value < 0) {
254
+ throw new Error("Usage: runcap cap <usd> (a non-negative number).");
255
+ }
256
+ await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: value, source, setAt: new Date().toISOString() }, null, 2));
257
+ const envNote = process.env.AIM_DAILY_BUDGET_USD
258
+ ? "\nNote: AIM_DAILY_BUDGET_USD is set in your env and overrides this file."
259
+ : "";
260
+ return `Hard cap set: $${value.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")}. Saved to ${BUDGET_FILE}.${envNote}`;
261
+ }
262
+
263
+ export async function clearBudgetCap() {
264
+ await ensureStore();
265
+ if (existsSync(BUDGET_FILE)) await writeFile(BUDGET_FILE, JSON.stringify({ capUsd: null, clearedAt: new Date().toISOString() }, null, 2));
266
+ return "Stored cap cleared. The gateway will only enforce AIM_DAILY_BUDGET_USD if set.";
267
+ }
268
+
269
+ export function currentBudgetCap() {
270
+ const cap = readBudget();
271
+ if (cap === null) return "No cap set. Run `runcap cap <usd>` or `runcap plan --apply-cap`.";
272
+ const src = process.env.AIM_DAILY_BUDGET_USD ? "env AIM_DAILY_BUDGET_USD" : `file ${BUDGET_FILE}`;
273
+ return `Current hard cap: $${cap.toFixed(2)} per ${(process.env.AIM_BUDGET_WINDOW ?? "day")} (from ${src}).`;
274
+ }
275
+
276
+ export function hasStoredCap() {
277
+ return readStoredBudget() !== null;
278
+ }
279
+
210
280
  export async function listPlans() {
211
281
  await ensureStore();
212
282
  const plans = await readPlans();
@@ -244,9 +314,15 @@ export async function setupProject() {
244
314
  "OPENAI_API_KEY=",
245
315
  "AIM_UPSTREAM_BASE_URL=https://api.openai.com/v1",
246
316
  "",
247
- "# Optional budget guard. If estimated spend already exceeds this, gateway blocks new calls.",
317
+ "# Hard cap (USD) per budget window. The gateway prices each call from its",
318
+ "# own tokens and blocks it BEFORE forwarding if it would push spend over the cap.",
319
+ "# You can also set this with `runcap cap <usd>` or `runcap plan --apply-cap`.",
248
320
  "AIM_DAILY_BUDGET_USD=5",
249
321
  "",
322
+ "# Budget window: day (default, rolling 24h), session (since gateway start),",
323
+ "# all (never resets), or a number of hours. Caps reset per window.",
324
+ "AIM_BUDGET_WINDOW=day",
325
+ "",
250
326
  "# For demo mode without external API calls:",
251
327
  "AIM_GATEWAY_MODE=mock"
252
328
  ].join("\n");
@@ -292,6 +368,80 @@ export async function doctor() {
292
368
  ].join("\n");
293
369
  }
294
370
 
371
+ // Guided first-run, shown when `runcap` is invoked with no arguments. Explains
372
+ // in one screen what Runcap does, what it does NOT do, checks readiness, and
373
+ // gives exactly ONE next step based on the current state — so a newcomer reaches
374
+ // their first result without reading docs.
375
+ export async function welcome() {
376
+ await ensureStore();
377
+ const hasOpenAiKey = Boolean(process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY);
378
+ const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY);
379
+ const hasAnyKey = hasOpenAiKey || hasAnthropicKey;
380
+ const cap = readBudget();
381
+ const gateway = await readGatewaySummary({ windowMs: budgetWindowMs() });
382
+ const window = process.env.AIM_BUDGET_WINDOW ?? "day";
383
+
384
+ const tick = (ok) => (ok ? "[x]" : "[ ]");
385
+ const keyLabel = hasAnyKey
386
+ ? `API key detected (${[hasAnthropicKey && "Anthropic", hasOpenAiKey && "OpenAI"].filter(Boolean).join(" + ")})`
387
+ : "No API key in this shell (set ANTHROPIC_API_KEY or OPENAI_API_KEY)";
388
+ const capLabel = cap === null ? "No cap set yet" : `Cap set: $${cap.toFixed(2)} per ${window}`;
389
+
390
+ // One next step, chosen by what is missing.
391
+ let nextStep;
392
+ if (!hasAnyKey) {
393
+ nextStep = [
394
+ "Next: give Runcap the same provider key your agent already uses, e.g.",
395
+ " export ANTHROPIC_API_KEY=sk-... # or OPENAI_API_KEY=sk-...",
396
+ "Then run `runcap` again."
397
+ ];
398
+ } else if (cap === null) {
399
+ nextStep = [
400
+ "Next: set the most you want a run to spend, then run your agent through Runcap:",
401
+ " runcap cap 5",
402
+ " runcap run -- claude \"fix the failing test\"",
403
+ "Runcap starts a local gateway, points your agent at it, and blocks any call",
404
+ "that would push spend over $5, before it reaches the paid API.",
405
+ "",
406
+ "Not sure what to cap at? Estimate first:",
407
+ " runcap plan --apply-cap -- \"the task you're about to run\""
408
+ ];
409
+ } else {
410
+ nextStep = [
411
+ `You're ready. Cap is $${cap.toFixed(2)} per ${window}. Run any agent through Runcap:`,
412
+ " runcap run -- claude \"fix the failing test\"",
413
+ " runcap run -- codex \"...\" runcap run -- python my_agent.py",
414
+ "",
415
+ gateway.callCount > 0
416
+ ? `Spent so far this ${window}: $${gateway.estimatedCostUsd.toFixed(4)} across ${gateway.callCount} calls. See: runcap status`
417
+ : "No calls recorded yet. Your first `runcap run` will show the spend."
418
+ ];
419
+ }
420
+
421
+ return [
422
+ "Runcap: see and cap what your AI agent spends, before it spends it.",
423
+ "",
424
+ "What it does:",
425
+ " - Prices each call your agent makes from its own tokens.",
426
+ " - Blocks any call that would exceed your cap BEFORE it hits the paid API.",
427
+ " - Shows you the real spend, per run and per day.",
428
+ "",
429
+ "What it does NOT do (so there are no surprises):",
430
+ " - It does not give you an AI model. You bring your own provider API key.",
431
+ " - It does not run tasks for you. You bring your own agent (Claude Code,",
432
+ " Codex, a script: anything that calls OpenAI/Anthropic).",
433
+ " - It is a local tool for that setup, not a no-account web app.",
434
+ "",
435
+ "Readiness:",
436
+ ` ${tick(hasAnyKey)} ${keyLabel}`,
437
+ ` ${tick(cap !== null)} ${capLabel}`,
438
+ "",
439
+ ...nextStep,
440
+ "",
441
+ "Full command list: `runcap help`."
442
+ ].join("\n");
443
+ }
444
+
295
445
  export async function startDashboard({ port = 8791 } = {}) {
296
446
  await ensureStore();
297
447
  const server = http.createServer(async (request, response) => {
@@ -350,13 +500,15 @@ async function listenLocal(server, port, label) {
350
500
  });
351
501
  }
352
502
 
353
- export async function startGateway({ port = 8792, mock = false } = {}) {
354
- await ensureStore();
503
+ // Build (but do not start) the gateway HTTP server. Upstream targets are
504
+ // captured here from explicit args or env, so the auto-wrapper can pin the real
505
+ // upstream BEFORE it rewrites the child's base URLs to point at this gateway.
506
+ function createGatewayServer({ port = 8792, mock = false, upstream = {} } = {}) {
355
507
  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";
508
+ const openaiKey = upstream.openaiKey ?? process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
509
+ const anthropicKey = upstream.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
510
+ const openaiBaseUrl = upstream.openaiBaseUrl ?? process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
511
+ const anthropicBaseUrl = upstream.anthropicBaseUrl ?? process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
360
512
  const anthropicVersion = process.env.ANTHROPIC_VERSION ?? "2023-06-01";
361
513
  if (gatewayMode !== "mock" && !openaiKey && !anthropicKey) {
362
514
  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 +536,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
384
536
  const bodyText = await readRequestBody(request);
385
537
  const requestBody = safeJson(bodyText) ?? {};
386
538
  const budget = readBudget();
387
- const summary = await readGatewaySummary();
388
- if (budget !== null && summary.estimatedCostUsd >= budget) {
539
+ const summary = await readGatewaySummary({ windowMs: budgetWindowMs() });
540
+ // Compress the request body once (safe, lossless-by-construction). Disable with AIM_COMPRESS=off.
541
+ const compressionOn = (process.env.AIM_COMPRESS ?? "on").toLowerCase() !== "off";
542
+ let forwardBody = bodyText;
543
+ let compression = null;
544
+ if (compressionOn) {
545
+ const c = compressRequestBody(requestBody);
546
+ if (c.savedChars > 0 && c.touched > 0) {
547
+ forwardBody = JSON.stringify(c.body);
548
+ compression = {
549
+ savedTokens: c.savedTokens,
550
+ savedChars: c.savedChars,
551
+ beforeChars: c.before,
552
+ afterChars: c.after,
553
+ fieldsTouched: c.touched,
554
+ truth: "estimated"
555
+ };
556
+ }
557
+ }
558
+ // Pre-call cap: price THIS request from its own tokens and block before
559
+ // forwarding if (already spent in the window + this call) would exceed the
560
+ // cap. Catches both accumulated overspend and a single oversized call.
561
+ const preCall = estimateRequestCost(requestBody);
562
+ const callEstimate = preCall.estimatedUsd ?? 0;
563
+ const projectedCostUsd = Number((summary.estimatedCostUsd + callEstimate).toFixed(6));
564
+ if (budget !== null && projectedCostUsd > budget) {
565
+ const blockedByThisCall = summary.estimatedCostUsd < budget;
389
566
  const event = {
390
567
  at: new Date().toISOString(),
391
568
  path: url.pathname,
@@ -395,11 +572,39 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
395
572
  usage: null,
396
573
  cost: null,
397
574
  truth: "budget_guard",
398
- error: `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
575
+ guard: {
576
+ spentUsd: summary.estimatedCostUsd,
577
+ callEstimateUsd: callEstimate,
578
+ callEstimateTruth: preCall.truth,
579
+ projectedUsd: projectedCostUsd,
580
+ capUsd: budget,
581
+ blockedByThisCall
582
+ },
583
+ error: blockedByThisCall
584
+ ? `Budget would be exceeded by this call: $${summary.estimatedCostUsd} spent + ~$${callEstimate} this call > cap $${budget}`
585
+ : `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
399
586
  requestHash: createHash("sha1").update(bodyText).digest("hex")
400
587
  };
401
588
  await appendGatewayEvent(event);
402
- sendJson(response, { error: event.error, truth: event.truth }, 429);
589
+ sendJson(response, { error: event.error, truth: event.truth, guard: event.guard }, 429);
590
+ const breachText = blockedByThisCall
591
+ ? `Runcap: cap protected. Blocked a ~$${callEstimate} call on ${event.model} before it ran ($${summary.estimatedCostUsd} already spent, cap $${budget}).`
592
+ : `Runcap: cap hit. Run blocked at $${summary.estimatedCostUsd} (cap $${budget}) on ${event.model}. The gateway stopped the call before it could spend more.`;
593
+ sendAlert(breachText)
594
+ .then((channels) => {
595
+ if (channels && channels.length) console.log(`Cap-breach alert sent to: ${channels.join(", ")}`);
596
+ })
597
+ .catch(() => {});
598
+ syncRun({
599
+ mission_id: null,
600
+ label: `gateway cap breach (${event.model})`,
601
+ estimate_low: budget,
602
+ estimate_high: projectedCostUsd,
603
+ cap: budget,
604
+ actual: summary.estimatedCostUsd,
605
+ capped: true,
606
+ status: "capped"
607
+ }).catch(() => {});
403
608
  return;
404
609
  }
405
610
  if (gatewayMode === "mock") {
@@ -414,6 +619,7 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
414
619
  durationMs: Date.now() - started,
415
620
  usage: responseBody.usage,
416
621
  cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
622
+ compression,
417
623
  truth: "mock_provider_usage",
418
624
  requestHash: createHash("sha1").update(bodyText).digest("hex")
419
625
  });
@@ -437,13 +643,16 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
437
643
  "authorization": `Bearer ${upstreamKey}`,
438
644
  "content-type": request.headers["content-type"] ?? "application/json"
439
645
  };
440
- // Anthropic base URLs already include /v1; avoid doubling it.
441
- const pathForUpstream = isAnthropic ? url.pathname.replace(/^\/v1/, "") : url.pathname;
646
+ // Both default upstream base URLs already include /v1, and the child calls
647
+ // us at /v1/*. Strip the leading /v1 from the path when the upstream base
648
+ // already ends in /v1, so we never produce a doubled /v1/v1 (OpenAI 404).
649
+ const baseHasV1 = /\/v1\/?$/.test(upstreamBase);
650
+ const pathForUpstream = baseHasV1 ? url.pathname.replace(/^\/v1/, "") : url.pathname;
442
651
  const upstreamUrl = `${upstreamBase.replace(/\/$/, "")}${pathForUpstream}`;
443
652
  const upstreamResponse = await fetch(upstreamUrl, {
444
653
  method: "POST",
445
654
  headers,
446
- body: bodyText
655
+ body: forwardBody
447
656
  });
448
657
  const responseText = await upstreamResponse.text();
449
658
  response.writeHead(upstreamResponse.status, {
@@ -461,9 +670,23 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
461
670
  durationMs: Date.now() - started,
462
671
  usage: responseBody.usage ?? null,
463
672
  cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
673
+ compression,
464
674
  truth: responseBody.usage ? "provider_usage" : "unknown",
465
675
  requestHash: createHash("sha1").update(bodyText).digest("hex")
466
676
  });
677
+ if (responseBody.usage) {
678
+ const spent = await readGatewaySummary({ windowMs: budgetWindowMs() });
679
+ syncRun({
680
+ mission_id: null,
681
+ label: "gateway session (actual spend)",
682
+ estimate_low: spent.estimatedCostUsd,
683
+ estimate_high: spent.estimatedCostUsd,
684
+ cap: budget,
685
+ actual: spent.estimatedCostUsd,
686
+ capped: false,
687
+ status: "running"
688
+ }).catch(() => {});
689
+ }
467
690
  } catch (error) {
468
691
  await appendGatewayEvent({
469
692
  at: new Date().toISOString(),
@@ -479,6 +702,13 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
479
702
  sendJson(response, { error: error.message }, 500);
480
703
  }
481
704
  });
705
+ return { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl };
706
+ }
707
+
708
+ export async function startGateway({ port = 8792, mock = false } = {}) {
709
+ await ensureStore();
710
+ const { server, gatewayMode, openaiKey, anthropicKey, openaiBaseUrl, anthropicBaseUrl } =
711
+ createGatewayServer({ port, mock });
482
712
  await listenLocal(server, port, "gateway");
483
713
  console.log(`Runcap gateway: http://127.0.0.1:${port}/v1`);
484
714
  console.log(`Mode: ${gatewayMode}`);
@@ -491,6 +721,33 @@ export async function startGateway({ port = 8792, mock = false } = {}) {
491
721
  console.log("Press Ctrl+C to stop.");
492
722
  }
493
723
 
724
+ // Start the gateway on an ephemeral free port for the duration of one wrapped
725
+ // run, returning a handle the wrapper uses to point the child at it and to shut
726
+ // it down afterward. Upstream is pinned from the CURRENT env before the child's
727
+ // base URLs are rewritten, so the gateway proxies to the real provider, not to
728
+ // itself.
729
+ async function startEphemeralGateway({ mock = false } = {}) {
730
+ await ensureStore();
731
+ const upstream = {
732
+ openaiKey: process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY,
733
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
734
+ openaiBaseUrl: process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
735
+ anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
736
+ };
737
+ const { server, gatewayMode } = createGatewayServer({ port: 0, mock, upstream });
738
+ await new Promise((resolve, reject) => {
739
+ server.once("error", reject);
740
+ server.listen(0, "127.0.0.1", resolve);
741
+ });
742
+ const actualPort = server.address().port;
743
+ return {
744
+ port: actualPort,
745
+ baseUrl: `http://127.0.0.1:${actualPort}`,
746
+ gatewayMode,
747
+ close: () => new Promise((resolve) => server.close(resolve))
748
+ };
749
+ }
750
+
494
751
  export async function showStatus(options = {}) {
495
752
  await ensureStore();
496
753
  const fuel = await readFuel();
@@ -588,7 +845,9 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
588
845
  ].filter(Boolean).length;
589
846
  const hasRepo = Boolean(snapshot.packageJson);
590
847
  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;
848
+ const fuel = fuelPercent === null || fuelPercent === undefined || fuelPercent === "" || !Number.isFinite(Number(fuelPercent))
849
+ ? null
850
+ : Number(fuelPercent);
592
851
  const budgetRisk = bigSignals > 0 || (fuel !== null && fuel < 30) ? "High" : fuel !== null && fuel < 55 ? "Medium" : "Low";
593
852
  const expectedWasteReduction = budgetRisk === "High" ? "40-70%" : budgetRisk === "Medium" ? "25-45%" : "10-25%";
594
853
  const qualityRisk = quality === "cheap" && budgetRisk === "High" ? "High" : budgetRisk === "High" ? "Medium" : "Low";
@@ -749,13 +1008,13 @@ function commandTemplatesForPlan(goal, missions) {
749
1008
  }));
750
1009
  }
751
1010
 
752
- async function runChild(command, cwd) {
1011
+ async function runChild(command, cwd, extraEnv = {}) {
753
1012
  const started = Date.now();
754
1013
  const [program, ...args] = command;
755
1014
  return await new Promise((resolve) => {
756
1015
  const child = spawn(program, args, {
757
1016
  cwd,
758
- env: { ...process.env, AIM_WRAPPED: "1" },
1017
+ env: { ...process.env, AIM_WRAPPED: "1", ...extraEnv },
759
1018
  shell: false
760
1019
  });
761
1020
  let stdout = "";
@@ -1099,6 +1358,7 @@ async function dashboardStatus() {
1099
1358
  return {
1100
1359
  fuel,
1101
1360
  gateway,
1361
+ budget: readBudget(),
1102
1362
  missionCount: missions.length,
1103
1363
  latest: missions[0] ?? null,
1104
1364
  counts: missions.reduce((acc, mission) => {
@@ -1118,26 +1378,83 @@ async function readGatewayEvents() {
1118
1378
  return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
1119
1379
  }
1120
1380
 
1121
- async function readGatewaySummary() {
1122
- const events = await readGatewayEvents();
1381
+ async function readGatewaySummary({ windowMs } = {}) {
1382
+ const allEvents = await readGatewayEvents();
1383
+ // When a window is given (used by the budget guard), only count spend whose
1384
+ // timestamp falls inside it. The cap is then a per-window budget that resets,
1385
+ // not an all-time counter that locks the gateway forever.
1386
+ const events = windowMs
1387
+ ? allEvents.filter((event) => {
1388
+ const t = Date.parse(event.at ?? "");
1389
+ return Number.isFinite(t) && Date.now() - t <= windowMs;
1390
+ })
1391
+ : allEvents;
1123
1392
  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);
1393
+ const totalTokens = events.reduce((sum, event) => {
1394
+ const u = event.usage;
1395
+ if (!u) return sum;
1396
+ const total = Number(u.total_tokens ?? 0) ||
1397
+ Number(u.prompt_tokens ?? u.input_tokens ?? 0) + Number(u.completion_tokens ?? u.output_tokens ?? 0);
1398
+ return sum + total;
1399
+ }, 0);
1125
1400
  const estimatedCost = events.reduce((sum, event) => sum + Number(event.cost?.estimatedUsd ?? 0), 0);
1401
+ const savedTokens = events.reduce((sum, event) => sum + Number(event.compression?.savedTokens ?? 0), 0);
1402
+ // Value the saved tokens at a blended input rate from the price table so we can
1403
+ // show one honest dollar figure. Per saved input token: use the model's input rate.
1404
+ const savedUsd = events.reduce((sum, event) => {
1405
+ const saved = Number(event.compression?.savedTokens ?? 0);
1406
+ if (!saved) return sum;
1407
+ const pricing = modelPricing(event.model);
1408
+ const inputRate = pricing ? pricing.inputPerMillion : 3; // fall back to a mid Sonnet-ish rate
1409
+ return sum + (saved * inputRate) / 1_000_000;
1410
+ }, 0);
1126
1411
  return {
1127
1412
  callCount: events.length,
1128
1413
  successfulCallCount: successful.length,
1129
1414
  totalTokens,
1130
1415
  estimatedCostUsd: Number(estimatedCost.toFixed(6)),
1416
+ savedTokens,
1417
+ savedUsd: Number(savedUsd.toFixed(6)),
1418
+ wouldHaveSpentUsd: Number((estimatedCost + savedUsd).toFixed(6)),
1131
1419
  truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
1132
1420
  ? "usage_plus_static_price_table"
1133
1421
  : "unknown",
1422
+ windowMs: windowMs ?? null,
1134
1423
  recent: events.slice(-20).reverse()
1135
1424
  };
1136
1425
  }
1137
1426
 
1427
+ // How wide the budget window is, in ms. AIM_BUDGET_WINDOW controls it:
1428
+ // "day" (default) → rolling 24h, "session" → since gateway start, "all" → no reset.
1429
+ const GATEWAY_STARTED_AT = Date.now();
1430
+ function budgetWindowMs() {
1431
+ const mode = (process.env.AIM_BUDGET_WINDOW ?? "day").toLowerCase();
1432
+ if (mode === "all") return undefined;
1433
+ if (mode === "session") return Date.now() - GATEWAY_STARTED_AT;
1434
+ const hours = Number(mode);
1435
+ if (Number.isFinite(hours) && hours > 0) return hours * 60 * 60 * 1000;
1436
+ return 24 * 60 * 60 * 1000; // "day" default
1437
+ }
1438
+
1439
+ // The cap value. Precedence: AIM_DAILY_BUDGET_USD env > persisted budget.json
1440
+ // (written by `runcap plan` / `runcap cap`). Null means no cap is set.
1138
1441
  function readBudget() {
1139
1442
  const raw = process.env.AIM_DAILY_BUDGET_USD;
1140
- if (raw === undefined || raw === "") return null;
1443
+ if (raw !== undefined && raw !== "") {
1444
+ const value = Number(raw);
1445
+ if (Number.isFinite(value) && value >= 0) return value;
1446
+ }
1447
+ const stored = readStoredBudget();
1448
+ return stored;
1449
+ }
1450
+
1451
+ function readStoredBudget() {
1452
+ if (!existsSync(BUDGET_FILE)) return null;
1453
+ let text = null;
1454
+ try { text = readFileSync(BUDGET_FILE, "utf8"); } catch { return null; }
1455
+ const parsed = safeJson(text);
1456
+ const raw = parsed?.capUsd;
1457
+ if (raw === null || raw === undefined || raw === "") return null;
1141
1458
  const value = Number(raw);
1142
1459
  return Number.isFinite(value) && value >= 0 ? value : null;
1143
1460
  }
@@ -1254,6 +1571,43 @@ function estimateApiCost(usage, model) {
1254
1571
  };
1255
1572
  }
1256
1573
 
1574
+ // Estimate the cost of a request BEFORE it is forwarded upstream, from the
1575
+ // request body alone. Input tokens are estimated from the serialized prompt;
1576
+ // output tokens from the caller's max_tokens (the worst case the provider can
1577
+ // bill). Returns null when the model has no verified price, so the guard can
1578
+ // decide whether to fail open or closed rather than guessing a number.
1579
+ function estimateRequestCost(requestBody) {
1580
+ const model = requestBody?.model ?? "";
1581
+ const pricing = modelPricing(model);
1582
+ if (!pricing) return { estimatedUsd: null, truth: "unknown_price", model };
1583
+
1584
+ const promptText = JSON.stringify(
1585
+ requestBody.messages ?? requestBody.system ?? requestBody.input ?? requestBody.prompt ?? ""
1586
+ );
1587
+ const inputTokens = estimateTokens(promptText);
1588
+ // Worst-case output the provider could bill: honor the caller's stated cap,
1589
+ // else assume a generous default so the guard is not fooled by an open-ended call.
1590
+ const maxOutput = Number(
1591
+ requestBody.max_tokens ??
1592
+ requestBody.max_completion_tokens ??
1593
+ requestBody.max_output_tokens ??
1594
+ 4096
1595
+ );
1596
+ const outputTokens = Number.isFinite(maxOutput) && maxOutput > 0 ? maxOutput : 4096;
1597
+
1598
+ const estimatedUsd =
1599
+ (inputTokens / 1_000_000) * pricing.inputPerMillion +
1600
+ (outputTokens / 1_000_000) * pricing.outputPerMillion;
1601
+
1602
+ return {
1603
+ estimatedUsd: Number(estimatedUsd.toFixed(6)),
1604
+ truth: "pre_call_estimate_from_request",
1605
+ model,
1606
+ inputTokens,
1607
+ outputTokens
1608
+ };
1609
+ }
1610
+
1257
1611
  function modelPricing(model = "") {
1258
1612
  const name = String(model).toLowerCase();
1259
1613
  const batch = name.includes("batch");
@@ -1528,78 +1882,96 @@ function renderDashboardHtml() {
1528
1882
  <meta charset="utf-8">
1529
1883
  <meta name="viewport" content="width=device-width, initial-scale=1">
1530
1884
  <title>Runcap</title>
1885
+ <link rel="preconnect" href="https://api.fontshare.com" crossorigin>
1886
+ <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
1887
  <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; }
1888
+ :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
1889
  * { 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); }
1890
+ 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); }
1891
+ 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
1892
  button, textarea, select, input { font:inherit; }
1537
1893
  .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; }
1894
+ aside { border-right:1px solid var(--line); background:var(--panel); padding:22px; overflow:auto; }
1895
+ main { padding:32px 36px; overflow:auto; }
1896
+ h1 { margin:0; font-family:"Clash Display", sans-serif; font-weight:700; font-size:23px; letter-spacing:-0.01em; }
1897
+ h2 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:34px; line-height:1.08; letter-spacing:-0.02em; }
1898
+ h3 { margin:0; font-family:"Clash Display", sans-serif; font-weight:600; font-size:15px; }
1543
1899
  p { margin:0; }
1544
1900
  .muted { color:var(--muted); }
1545
1901
  .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; }
1902
+ .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); }
1903
+ .tagline { color:var(--muted); font-size:13px; margin-top:3px; line-height:1.35; }
1548
1904
  .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; }
1905
+ .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; }
1906
+ .nav button.active, .nav button:hover { border-color:var(--accent); background:#f5f4ff; }
1907
+ .nav strong { display:block; font-weight:600; }
1552
1908
  .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; }
1909
+ .side-title { margin:18px 0 10px; color:var(--muted); font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; }
1554
1910
  .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; }
1911
+ .mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:var(--panel); border-radius:14px; }
1912
+ .mini { padding:13px; min-height:76px; box-shadow:var(--shadow); }
1913
+ .mini strong { display:block; font-family:"JetBrains Mono",monospace; font-size:22px; font-weight:500; }
1558
1914
  .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; }
1915
+ .mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:13px; transition:all .15s; }
1560
1916
  .mission:hover, .mission.active { border-color:var(--accent); }
1561
- .mission.active { background:#142232; box-shadow: inset 3px 0 0 var(--accent); }
1917
+ .mission.active { background:#f5f4ff; box-shadow: inset 3px 0 0 var(--accent); }
1562
1918
  .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; }
1919
+ .mission-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1564
1920
  .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); }
1921
+ .status { font-size:12px; border:1px solid var(--line); padding:4px 9px; border-radius:999px; white-space:nowrap; font-weight:500; }
1922
+ .stuck { color:var(--bad); border-color:rgba(220,38,38,0.35); background:rgba(220,38,38,0.05); }
1923
+ .at_risk { color:var(--warn); border-color:rgba(183,121,31,0.35); background:rgba(183,121,31,0.05); }
1924
+ .progressing { color:var(--good); border-color:rgba(13,159,110,0.35); background:rgba(13,159,110,0.05); }
1569
1925
  .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; }
1926
+ .panel { padding:26px; box-shadow:var(--shadow); }
1927
+ .hero-copy { color:var(--muted); font-size:16px; line-height:1.55; margin-top:14px; max-width:880px; }
1928
+ /* SAVINGS HERO — the one visible number (Kirill's core fix) */
1929
+ .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); }
1930
+ .savings-label { font-size:12px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--muted); }
1931
+ .savings-row { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; margin-top:8px; }
1932
+ .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; }
1933
+ .savings-unit { font-family:"JetBrains Mono",monospace; font-size:17px; color:var(--muted); padding-bottom:8px; }
1934
+ .savings-sub { color:var(--muted); font-size:15px; margin-top:12px; }
1935
+ .savings-sub b { color:var(--text); font-family:"JetBrains Mono",monospace; font-weight:500; }
1936
+ .capbar { margin-top:18px; }
1937
+ .capbar-track { height:12px; border-radius:999px; background:var(--soft); overflow:hidden; border:1px solid var(--line); }
1938
+ .capbar-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--good),var(--accent)); transition:width .4s; }
1939
+ .capbar-fill.warn { background:linear-gradient(90deg,var(--warn),#e8590c); }
1940
+ .capbar-fill.over { background:linear-gradient(90deg,var(--bad),#991b1b); }
1941
+ .capbar-meta { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-top:7px; font-family:"JetBrains Mono",monospace; }
1572
1942
  .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); }
1943
+ .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); }
1944
+ .badge.good { color:var(--good); border-color:rgba(13,159,110,0.35); }
1945
+ .badge.warn { color:var(--warn); border-color:rgba(183,121,31,0.35); }
1946
+ .badge.bad { color:var(--bad); border-color:rgba(220,38,38,0.35); }
1577
1947
  .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; }
1948
+ .metric { padding:15px; box-shadow:var(--shadow); }
1949
+ .metric strong { display:block; font-family:"JetBrains Mono",monospace; font-size:23px; font-weight:500; line-height:1.1; }
1580
1950
  .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; }
1951
+ .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
1952
  .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; }
1953
+ select, input { width:100%; background:var(--panel2); color:var(--text); border:1px solid var(--line); border-radius:11px; padding:11px; }
1954
+ label { display:block; color:var(--muted); font-size:12px; font-weight:600; margin:0 0 7px; }
1955
+ .primary, .ghost { border-radius:11px; padding:11px 16px; cursor:pointer; font-weight:600; transition:all .15s; }
1956
+ .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); }
1957
+ .primary:hover { filter:brightness(1.06); transform:translateY(-1px); }
1958
+ .ghost { border:1px solid var(--line); color:var(--text); background:var(--panel); }
1959
+ .ghost:hover { border-color:var(--accent); }
1588
1960
  .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
1589
1961
  .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; }
1962
+ .plan-card { padding:18px; box-shadow:var(--shadow); }
1963
+ .plan-card strong { display:block; margin-bottom:8px; font-weight:600; }
1592
1964
  .plan-card p, .step p { color:var(--muted); line-height:1.48; }
1593
1965
  .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; }
1966
+ .step { padding:16px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; box-shadow:var(--shadow); }
1967
+ .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; }
1968
+ .rescue { border-color:rgba(79,70,229,0.4); background:linear-gradient(135deg,#ffffff,#f7f6ff); }
1969
+ .decision { color:var(--bad); font-weight:600; font-size:18px; margin:8px 0 0; }
1970
+ 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; }
1971
+ details { padding:14px 18px; margin-top:14px; box-shadow:var(--shadow); }
1972
+ summary { cursor:pointer; color:var(--muted); font-weight:600; }
1601
1973
  .hidden { display:none; }
1602
- .empty { padding:40px; text-align:left; }
1974
+ .empty { padding:42px; text-align:left; }
1603
1975
  .copy { margin-top:10px; }
1604
1976
  @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
1977
  </style>
@@ -1608,10 +1980,10 @@ function renderDashboardHtml() {
1608
1980
  <div class="app">
1609
1981
  <aside>
1610
1982
  <div class="brand">
1611
- <div class="mark">AI</div>
1983
+ <div class="mark">R</div>
1612
1984
  <div>
1613
1985
  <h1>Runcap</h1>
1614
- <div class="tagline">Plan AI work. Route models. Prove progress. Stop waste.</div>
1986
+ <div class="tagline">Estimate cost. Cap spend. Compress tokens. Rescue stuck runs.</div>
1615
1987
  </div>
1616
1988
  </div>
1617
1989
  <div class="nav">
@@ -1624,10 +1996,10 @@ function renderDashboardHtml() {
1624
1996
  <div class="mission-line" id="truth">Gateway truth: loading...</div>
1625
1997
  </div>
1626
1998
  <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>
1999
+ <div class="mini"><strong id="cost">$0</strong><span>spent so far</span></div>
2000
+ <div class="mini"><strong id="saved" style="color:var(--good)">$0</strong><span>saved by compression</span></div>
1629
2001
  <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>
2002
+ <div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
1631
2003
  </div>
1632
2004
  <div class="side-title">Saved plans</div>
1633
2005
  <div id="plans"></div>
@@ -1652,13 +2024,16 @@ function renderDashboardHtml() {
1652
2024
  state.plans = plans;
1653
2025
  document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
1654
2026
  document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
1655
- document.getElementById("total").textContent = status.missionCount;
1656
2027
  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;
2028
+ document.getElementById("tokens").textContent = Number(status.gateway.totalTokens || 0).toLocaleString();
2029
+ document.getElementById("cost").textContent = "$" + (status.gateway.estimatedCostUsd ?? 0);
2030
+ document.getElementById("saved").textContent = "$" + (status.gateway.savedUsd ?? 0);
2031
+ state.gateway = status.gateway;
2032
+ state.budget = status.budget;
1659
2033
  renderList();
1660
2034
  renderPlans();
1661
2035
  if (!state.plannerRendered) renderPlanner(status);
2036
+ renderSavingsHero(status.gateway);
1662
2037
  if (!state.selected && missions[0]) showMission(missions[0].id, false);
1663
2038
  if (!missions[0]) renderEmptyMonitor();
1664
2039
  }
@@ -1687,10 +2062,40 @@ function renderDashboardHtml() {
1687
2062
  '</button>'
1688
2063
  ).join("");
1689
2064
  }
2065
+ function renderSavingsHero(g) {
2066
+ const el = document.getElementById("savings-hero");
2067
+ if (!el || !g) return;
2068
+ const saved = Number(g.savedUsd ?? 0);
2069
+ const tokens = Number(g.savedTokens ?? 0);
2070
+ const spent = Number(g.estimatedCostUsd ?? 0);
2071
+ const wouldHave = Number(g.wouldHaveSpentUsd ?? spent);
2072
+ const fmt = (n) => "$" + (n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2));
2073
+ if (tokens === 0 && spent === 0) {
2074
+ el.innerHTML = '<div class="savings"><div class="savings-label">Your savings will show here</div>' +
2075
+ '<div class="savings-row"><div class="savings-big">$0.00</div><div class="savings-unit">saved so far</div></div>' +
2076
+ '<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>';
2077
+ return;
2078
+ }
2079
+ // cap bar
2080
+ let capHtml = '';
2081
+ if (state.budget && state.budget > 0) {
2082
+ const pct = Math.min(100, (spent / state.budget) * 100);
2083
+ const cls = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : '';
2084
+ capHtml = '<div class="capbar"><div class="capbar-track"><div class="capbar-fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div></div>' +
2085
+ '<div class="capbar-meta"><span>spent ' + fmt(spent) + '</span><span>cap ' + fmt(state.budget) + '</span></div></div>';
2086
+ }
2087
+ el.innerHTML = '<div class="savings">' +
2088
+ '<div class="savings-label">You saved</div>' +
2089
+ '<div class="savings-row"><div class="savings-big">' + fmt(saved) + '</div><div class="savings-unit">' + tokens.toLocaleString() + ' tokens compressed away</div></div>' +
2090
+ '<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>' +
2091
+ capHtml +
2092
+ '</div>';
2093
+ }
1690
2094
  function renderPlanner(status) {
1691
2095
  state.plannerRendered = true;
1692
2096
  const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
1693
2097
  document.getElementById("plan-view").innerHTML =
2098
+ '<div id="savings-hero"></div>' +
1694
2099
  '<div class="hero">' +
1695
2100
  '<div class="panel">' +
1696
2101
  '<h2>Turn one expensive AI request into a managed plan.</h2>' +