runcap 0.1.0 → 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,11 +721,38 @@ 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();
497
754
  const fuelLine = fuel.currentPercent === null
498
- ? "Fuel: unknown. Run `aim fuel set <percent>` to calibrate subscription limits."
755
+ ? "Fuel: unknown. Run `runcap fuel set <percent>` to calibrate subscription limits."
499
756
  : `Fuel: ${fuel.currentPercent}% (${fuel.source}, confidence: ${fuel.confidence})`;
500
757
  if (options.includeFuelOnly) return fuelLine;
501
758
 
@@ -588,13 +845,16 @@ 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";
595
854
  const routing = routeTask({ taskType, budgetRisk, quality, hasVerification });
596
855
  const proof = proofForTask({ taskType, hasVerification });
597
856
  const missions = missionBreakdown({ taskType, budgetRisk, proof });
857
+ const cost = estimatePlanCost({ budgetRisk, bigSignals, words, taskType, quality });
598
858
  return {
599
859
  id: createPlanId(cleanGoal),
600
860
  createdAt: new Date().toISOString(),
@@ -609,6 +869,12 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
609
869
  budget: {
610
870
  risk: budgetRisk,
611
871
  expectedWasteReduction,
872
+ costLowUsd: cost.lowUsd,
873
+ costHighUsd: cost.highUsd,
874
+ costRange: cost.range,
875
+ recommendedCapUsd: cost.recommendedCapUsd,
876
+ recommendedCap: cost.recommendedCap,
877
+ costPrecision: cost.precision,
612
878
  reason: budgetRisk === "High"
613
879
  ? "The goal is broad or fuel is low. A single agent run is likely to waste context and repeat work."
614
880
  : "The goal can be controlled with smaller missions and proof checkpoints."
@@ -629,6 +895,49 @@ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot
629
895
  };
630
896
  }
631
897
 
898
+ // Estimate a USD cost RANGE for an agent run from scope signals, priced against
899
+ // the sourced table. Deliberately a range, not an oracle: agent runs are
900
+ // stochastic. The recommended cap sits above the high end so a normal run
901
+ // completes but a runaway loop is stopped.
902
+ function estimatePlanCost({ budgetRisk, bigSignals, words, taskType, quality }) {
903
+ // Base expected total tokens (input+output across the whole run, including
904
+ // the agent re-reading context on each loop). Software runs loop more.
905
+ let baseTokens = taskType === "software" ? 220000 : 120000;
906
+ baseTokens += words * 1500;
907
+ baseTokens += bigSignals * 350000;
908
+ if (budgetRisk === "High") baseTokens *= 2.4;
909
+ else if (budgetRisk === "Medium") baseTokens *= 1.5;
910
+
911
+ // Premium-model blended price ($/token): planning on a strong model is the
912
+ // expensive case, so we price the headline range against it to avoid
913
+ // under-promising. Opus-class: ~$5/M in, ~$25/M out, assume ~30% output.
914
+ const blendedPerToken = quality === "cheap"
915
+ ? (0.75 * 0.7 + 4.5 * 0.3) / 1_000_000 // cheap tier (gpt-5.4-mini)
916
+ : (5 * 0.7 + 25 * 0.3) / 1_000_000; // strong tier (opus-class)
917
+
918
+ const mid = baseTokens * blendedPerToken;
919
+ // Range: runs vary widely, so +-45% around the midpoint.
920
+ const lowUsd = round2(mid * 0.55);
921
+ const highUsd = round2(mid * 1.45);
922
+ // Cap above the high end (1.5x) so a normal run finishes, a loop is killed.
923
+ const recommendedCapUsd = roundCap(highUsd * 1.5);
924
+ return {
925
+ lowUsd,
926
+ highUsd,
927
+ recommendedCapUsd,
928
+ range: `$${lowUsd.toFixed(2)}-$${highUsd.toFixed(2)}`,
929
+ recommendedCap: `$${recommendedCapUsd.toFixed(2)}`,
930
+ precision: "calculated_estimate_not_provider_bill"
931
+ };
932
+ }
933
+
934
+ function round2(n) { return Math.round(n * 100) / 100; }
935
+ function roundCap(n) {
936
+ // Round caps to a friendly number: nearest $1 under $20, nearest $5 above.
937
+ if (n < 20) return Math.max(1, Math.ceil(n));
938
+ return Math.ceil(n / 5) * 5;
939
+ }
940
+
632
941
  function classifyTask(lower) {
633
942
  if (/code|bug|test|build|app|api|database|typescript|react|python|deploy|auth|repo|github/.test(lower)) return "software";
634
943
  if (/video|script|post|content|image|marketing|copy|campaign|linkedin|youtube/.test(lower)) return "creative";
@@ -699,13 +1008,13 @@ function commandTemplatesForPlan(goal, missions) {
699
1008
  }));
700
1009
  }
701
1010
 
702
- async function runChild(command, cwd) {
1011
+ async function runChild(command, cwd, extraEnv = {}) {
703
1012
  const started = Date.now();
704
1013
  const [program, ...args] = command;
705
1014
  return await new Promise((resolve) => {
706
1015
  const child = spawn(program, args, {
707
1016
  cwd,
708
- env: { ...process.env, AIM_WRAPPED: "1" },
1017
+ env: { ...process.env, AIM_WRAPPED: "1", ...extraEnv },
709
1018
  shell: false
710
1019
  });
711
1020
  let stdout = "";
@@ -1049,6 +1358,7 @@ async function dashboardStatus() {
1049
1358
  return {
1050
1359
  fuel,
1051
1360
  gateway,
1361
+ budget: readBudget(),
1052
1362
  missionCount: missions.length,
1053
1363
  latest: missions[0] ?? null,
1054
1364
  counts: missions.reduce((acc, mission) => {
@@ -1068,26 +1378,83 @@ async function readGatewayEvents() {
1068
1378
  return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
1069
1379
  }
1070
1380
 
1071
- async function readGatewaySummary() {
1072
- 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;
1073
1392
  const successful = events.filter((event) => event.status >= 200 && event.status < 300);
1074
- 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);
1075
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);
1076
1411
  return {
1077
1412
  callCount: events.length,
1078
1413
  successfulCallCount: successful.length,
1079
1414
  totalTokens,
1080
1415
  estimatedCostUsd: Number(estimatedCost.toFixed(6)),
1416
+ savedTokens,
1417
+ savedUsd: Number(savedUsd.toFixed(6)),
1418
+ wouldHaveSpentUsd: Number((estimatedCost + savedUsd).toFixed(6)),
1081
1419
  truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
1082
1420
  ? "usage_plus_static_price_table"
1083
1421
  : "unknown",
1422
+ windowMs: windowMs ?? null,
1084
1423
  recent: events.slice(-20).reverse()
1085
1424
  };
1086
1425
  }
1087
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.
1088
1441
  function readBudget() {
1089
1442
  const raw = process.env.AIM_DAILY_BUDGET_USD;
1090
- 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;
1091
1458
  const value = Number(raw);
1092
1459
  return Number.isFinite(value) && value >= 0 ? value : null;
1093
1460
  }
@@ -1204,6 +1571,43 @@ function estimateApiCost(usage, model) {
1204
1571
  };
1205
1572
  }
1206
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
+
1207
1611
  function modelPricing(model = "") {
1208
1612
  const name = String(model).toLowerCase();
1209
1613
  const batch = name.includes("batch");
@@ -1249,7 +1653,7 @@ function shortSummary(mission) {
1249
1653
 
1250
1654
  function formatPreflight({ command, preflight, fuel }) {
1251
1655
  const fuelLine = fuel.currentPercent === null
1252
- ? "Fuel: unknown. Set it with `aim fuel set <percent>` if using subscriptions."
1656
+ ? "Fuel: unknown. Set it with `runcap fuel set <percent>` if using subscriptions."
1253
1657
  : `Fuel: ${fuel.currentPercent}% (${fuel.confidence} confidence)`;
1254
1658
  const scopeAdvice = preflight.scopeRisk === "high"
1255
1659
  ? "Do not launch as one broad mission. Split into one vertical slice with a verification command."
@@ -1273,7 +1677,7 @@ function formatPreflight({ command, preflight, fuel }) {
1273
1677
 
1274
1678
  function formatReport(mission) {
1275
1679
  const fuel = mission.fuelUsedPercent === null
1276
- ? `Fuel: before ${mission.fuelBefore ?? "unknown"}%, after unknown. Calibrate with \`aim fuel calibrate ${mission.id} <after-percent>\`.`
1680
+ ? `Fuel: before ${mission.fuelBefore ?? "unknown"}%, after unknown. Calibrate with \`runcap fuel calibrate ${mission.id} <after-percent>\`.`
1277
1681
  : `Fuel used: ${mission.fuelUsedPercent}% (source: manual calibration, confidence: high).`;
1278
1682
  const errorLines = mission.errors.length
1279
1683
  ? mission.errors.map((error) => `- ${error.kind} (${error.confidence}): ${error.raw}`).join("\n")
@@ -1287,7 +1691,7 @@ function formatReport(mission) {
1287
1691
  ` Next action: ${rec.nextAction}`,
1288
1692
  ` Rescue prompt: ${rec.prompt}`
1289
1693
  ].join("\n")).join("\n\n");
1290
- return `# AI Mission Report
1694
+ return `# Runcap Mission Report
1291
1695
 
1292
1696
  Mission: ${mission.id}
1293
1697
  Command: \`${mission.command.join(" ")}\`
@@ -1396,7 +1800,7 @@ function formatHtmlReport(mission) {
1396
1800
  <head>
1397
1801
  <meta charset="utf-8">
1398
1802
  <meta name="viewport" content="width=device-width, initial-scale=1">
1399
- <title>AI Mission Report - ${escapeHtml(mission.label ?? mission.id)}</title>
1803
+ <title>Runcap Mission Report - ${escapeHtml(mission.label ?? mission.id)}</title>
1400
1804
  <style>
1401
1805
  :root { color-scheme: dark; --bg:#0f1115; --panel:#181c22; --soft:#202630; --line:#303946; --text:#f5f7fb; --muted:#a7b0bd; --accent:#70d6ff; --warn:#ffd166; --bad:#ff6b6b; --good:#55d78a; }
1402
1806
  * { box-sizing:border-box; }
@@ -1478,78 +1882,96 @@ function renderDashboardHtml() {
1478
1882
  <meta charset="utf-8">
1479
1883
  <meta name="viewport" content="width=device-width, initial-scale=1">
1480
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">
1481
1887
  <style>
1482
- :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); }
1483
1889
  * { box-sizing: border-box; }
1484
- 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); }
1485
- 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%); }
1486
1892
  button, textarea, select, input { font:inherit; }
1487
1893
  .app { position:relative; display:grid; grid-template-columns: 320px minmax(0,1fr); min-height:100vh; }
1488
- aside { border-right:1px solid var(--line); background:rgba(8,13,19,0.82); padding:22px; overflow:auto; }
1489
- main { padding:28px; overflow:auto; }
1490
- h1 { margin:0; font-size:24px; letter-spacing:0; }
1491
- h2 { margin:0; font-size:38px; line-height:1.06; letter-spacing:0; }
1492
- 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; }
1493
1899
  p { margin:0; }
1494
1900
  .muted { color:var(--muted); }
1495
1901
  .brand { display:flex; align-items:center; gap:12px; margin-bottom:22px; }
1496
- .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)); }
1497
- .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; }
1498
1904
  .nav { display:grid; gap:8px; margin:18px 0 22px; }
1499
- .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; }
1500
- .nav button.active, .nav button:hover { border-color:var(--accent); background:#152434; }
1501
- .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; }
1502
1908
  .nav span { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
1503
- .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; }
1504
1910
  .summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
1505
- .mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:rgba(17,24,33,0.9); border-radius:8px; }
1506
- .mini { padding:12px; min-height:76px; }
1507
- .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; }
1508
1914
  .mini span { color:var(--muted); font-size:12px; }
1509
- .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; }
1510
1916
  .mission:hover, .mission.active { border-color:var(--accent); }
1511
- .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); }
1512
1918
  .mission-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; }
1513
- .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; }
1514
1920
  .mission-line { color:var(--muted); font-size:13px; line-height:1.35; }
1515
- .status { font-size:12px; border:1px solid var(--line); padding:4px 8px; border-radius:999px; white-space:nowrap; }
1516
- .stuck { color:var(--bad); border-color:rgba(255,104,104,0.5); }
1517
- .at_risk { color:var(--warn); border-color:rgba(255,209,102,0.5); }
1518
- .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); }
1519
1925
  .hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(360px,0.8fr); gap:18px; margin-bottom:18px; }
1520
- .panel { padding:24px; }
1521
- .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; }
1522
1942
  .badge-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
1523
- .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; }
1524
- .badge.good { color:var(--good); border-color:rgba(82,215,137,0.48); }
1525
- .badge.warn { color:var(--warn); border-color:rgba(255,209,102,0.48); }
1526
- .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); }
1527
1947
  .metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:20px; }
1528
- .metric { padding:14px; }
1529
- .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; }
1530
1950
  .metric span { display:block; color:var(--muted); font-size:12px; margin-top:6px; }
1531
- .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; }
1532
1952
  .field-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
1533
- select, input { width:100%; background:#0a1017; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:10px; }
1534
- label { display:block; color:var(--muted); font-size:12px; font-weight:750; margin:0 0 7px; }
1535
- .primary, .ghost { border-radius:8px; padding:10px 13px; cursor:pointer; font-weight:800; }
1536
- .primary { border:1px solid rgba(99,213,255,0.7); color:#061017; background:linear-gradient(135deg, var(--accent), var(--good)); }
1537
- .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); }
1538
1960
  .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
1539
1961
  .plan-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }
1540
- .plan-card { padding:16px; }
1541
- .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; }
1542
1964
  .plan-card p, .step p { color:var(--muted); line-height:1.48; }
1543
1965
  .timeline { display:grid; gap:10px; margin-top:14px; }
1544
- .step { padding:15px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; }
1545
- .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; }
1546
- .rescue { border-color:rgba(99,213,255,0.55); background:#172433; }
1547
- .decision { color:var(--warn); font-weight:900; font-size:18px; margin:8px 0 0; }
1548
- 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; }
1549
- details { padding:14px 16px; margin-top:14px; }
1550
- 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; }
1551
1973
  .hidden { display:none; }
1552
- .empty { padding:40px; text-align:left; }
1974
+ .empty { padding:42px; text-align:left; }
1553
1975
  .copy { margin-top:10px; }
1554
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; } }
1555
1977
  </style>
@@ -1558,10 +1980,10 @@ function renderDashboardHtml() {
1558
1980
  <div class="app">
1559
1981
  <aside>
1560
1982
  <div class="brand">
1561
- <div class="mark">AI</div>
1983
+ <div class="mark">R</div>
1562
1984
  <div>
1563
1985
  <h1>Runcap</h1>
1564
- <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>
1565
1987
  </div>
1566
1988
  </div>
1567
1989
  <div class="nav">
@@ -1574,10 +1996,10 @@ function renderDashboardHtml() {
1574
1996
  <div class="mission-line" id="truth">Gateway truth: loading...</div>
1575
1997
  </div>
1576
1998
  <div class="summary">
1577
- <div class="mini"><strong id="total">0</strong><span>checks</span></div>
1578
- <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>
1579
2001
  <div class="mini"><strong id="tokens">0</strong><span>API tokens</span></div>
1580
- <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>
1581
2003
  </div>
1582
2004
  <div class="side-title">Saved plans</div>
1583
2005
  <div id="plans"></div>
@@ -1602,13 +2024,16 @@ function renderDashboardHtml() {
1602
2024
  state.plans = plans;
1603
2025
  document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
1604
2026
  document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
1605
- document.getElementById("total").textContent = status.missionCount;
1606
2027
  document.getElementById("needs").textContent = (status.counts.stuck ?? 0) + (status.counts.at_risk ?? 0);
1607
- document.getElementById("tokens").textContent = status.gateway.totalTokens;
1608
- 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;
1609
2033
  renderList();
1610
2034
  renderPlans();
1611
2035
  if (!state.plannerRendered) renderPlanner(status);
2036
+ renderSavingsHero(status.gateway);
1612
2037
  if (!state.selected && missions[0]) showMission(missions[0].id, false);
1613
2038
  if (!missions[0]) renderEmptyMonitor();
1614
2039
  }
@@ -1637,10 +2062,40 @@ function renderDashboardHtml() {
1637
2062
  '</button>'
1638
2063
  ).join("");
1639
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
+ }
1640
2094
  function renderPlanner(status) {
1641
2095
  state.plannerRendered = true;
1642
2096
  const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
1643
2097
  document.getElementById("plan-view").innerHTML =
2098
+ '<div id="savings-hero"></div>' +
1644
2099
  '<div class="hero">' +
1645
2100
  '<div class="panel">' +
1646
2101
  '<h2>Turn one expensive AI request into a managed plan.</h2>' +