runcap 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,15 +4,21 @@
4
4
 
5
5
  ![Runcap terminal demo: estimate, cap, compress, stop](docs/assets/demo.svg)
6
6
 
7
- **Know what your coding agent will cost before you build it, and set a hard ceiling so it never surprises you.**
7
+ **Your AI coding agent re-reads the same files over and over and quietly burns your money. Runcap estimates the bill before you build, hard-caps the spend so it physically stops at your ceiling, and losslessly compresses every call. Free, MIT, 100% local. Your code and tokens never touch a server.**
8
8
 
9
- Runcap estimates the cost of an agent run as a range, enforces a hard spend ceiling that physically stops the run, and when the agent gets stuck it hands you the exact rescue prompt. Free, MIT, 100% local. Your code and tokens never touch a server.
9
+ On a real OpenAI call, one edited-file re-read dropped from **1,186 to 737 prompt tokens (37.9% saved)** with the model still answering correctly about the changed line. No other proxy does this:
10
+
11
+ | | Without Runcap | With Runcap |
12
+ |---|---|---|
13
+ | Re-read of an edited file | 1,186 prompt tokens | **737 prompt tokens** |
14
+ | You find out the cost | when the invoice arrives | **before you press go, capped at your ceiling** |
15
+ | When the agent gets stuck | it keeps spending | **run stops, you get the exact rescue prompt** |
10
16
 
11
17
  > Every other tool here is a rear-view mirror - it shows you the bill *after* you paid it. Runcap estimates the bill *before* you start and caps it. It is a circuit breaker, not a dashboard.
12
18
 
13
19
  ## Why
14
20
 
15
- Multi-agent coding runs burn roughly **15x more tokens** than a single chat ([Anthropic engineering](https://www.anthropic.com/engineering/built-multi-agent-research-system)). Agents loop on the same error, rewrite plans, and hand you a confident summary while the task is not actually done. You find out what it cost when the invoice - or the subscription limit - arrives.
21
+ **Agents loop on the same error, rewrite plans, and re-read files they just edited - every loop is tokens you pay for.** Multi-agent coding runs burn roughly **15x more tokens** than a single chat ([Anthropic engineering](https://www.anthropic.com/engineering/built-multi-agent-research-system)). They hand you a confident summary while the task is not actually done, and you find out what it cost when the invoice - or the subscription limit - arrives.
16
22
 
17
23
  Observability tools (Langfuse, Helicone, LangSmith, AgentOps) measure the past. Gateways (LiteLLM, Portkey, OpenRouter) route the present. None of them stop the spend *before* it happens. Runcap does the one thing the rear-view mirror can't:
18
24
 
@@ -135,6 +141,12 @@ It's pure Node with **zero ML or native dependencies**, so it installs everywher
135
141
 
136
142
  The dashboard shows the result as one number: **"You saved $X · N tokens compressed · would have spent $Y."** Disable it with `AIM_COMPRESS=off` if you ever want raw passthrough.
137
143
 
144
+ ## Loop detection (the "looks productive but stuck" signal)
145
+
146
+ The hard case in stuck-detection is the agent that keeps producing output but is really circling the same failure, just reworded each time. Plain hashing misses it because the prompt is *similar but never byte-identical* between loops. Because the gateway sees every request, Runcap compares each request's conversation shape against the recent run with the same line-similarity primitive the delta-encoder uses: when several prompts in a row are near-identical (default: 3 prompts at 92%+ similarity) while the conversation never moves forward, it flags `loop.looping` on the event, surfaces a warning in `runcap status`, and fires an alert.
147
+
148
+ This is a **calculated** signal, not a proven dollar-saving: it tells you *"the agent has sent 3 near-identical prompts in a row with no progress"* so you can step in before the loop burns more budget. Tune or disable it with `AIM_LOOP_DETECT=off`. (Today's [`detectStuck`](src/mission-control.mjs) post-run score is outcome-based: exit code, parsed errors, and zero-diff. The loop signal adds the missing in-flight behavioral signal on top of it.)
149
+
138
150
  ## Pricing table
139
151
 
140
152
  Costs are calculated from a sourced multi-provider table - Anthropic (Opus / Sonnet / Haiku) and OpenAI (GPT-5 family + legacy GPT-4), with cache-read and batch discounts handled - labeled with source and verification date. When a model is unknown, Runcap says `unknown_price` rather than guessing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcap",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Cap every agent run before it starts: estimate cost, set a hard ceiling that stops the run, rescue stuck agents. Local, MIT, nothing uploaded.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,8 +45,9 @@
45
45
  "acceptance": "node ./scripts/acceptance.mjs",
46
46
  "smoke": "node ./bin/runcap.mjs run --label smoke -- npm --prefix examples/broken-ts-app run build",
47
47
  "demo:broken": "node ./bin/runcap.mjs run --label broken-ts-demo -- npm --prefix examples/broken-ts-app run build",
48
- "test": "node ./scripts/delta-test.mjs && node ./scripts/validate-demo.mjs",
48
+ "test": "node ./scripts/delta-test.mjs && node ./scripts/loop-test.mjs && node ./scripts/loop-e2e.mjs && node ./scripts/validate-demo.mjs",
49
49
  "test:delta": "node ./scripts/delta-test.mjs",
50
+ "test:loop": "node ./scripts/loop-test.mjs",
50
51
  "status": "node ./bin/runcap.mjs status",
51
52
  "report": "node ./bin/runcap.mjs report",
52
53
  "export": "node ./bin/runcap.mjs export",
@@ -0,0 +1,137 @@
1
+ // End-to-end proof that the response-side loop gate works through the REAL
2
+ // gateway over HTTP, not just in unit tests. We stand up a tiny local "upstream"
3
+ // that returns a caller-chosen error string, point the real Runcap gateway at
4
+ // it, and drive near-identical prompts through the wire:
5
+ // A) error CHANGES each turn (convergence) -> gateway must NOT flag a loop
6
+ // B) error STAYS the same each turn (circling) -> gateway MUST flag a loop
7
+ // The gateway records its loop verdict per call in the gateway event log, which
8
+ // we read back to assert the real server behaved correctly.
9
+ //
10
+ // Pure Node, no framework. Exits non-zero on any failure so it can gate CI.
11
+
12
+ import http from "node:http";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { mkdtempSync, readFileSync, existsSync } from "node:fs";
16
+
17
+ // Isolate all gateway state (the .runcap event log lives under cwd) in a
18
+ // throwaway dir so this never touches real data. The gateway writes its event
19
+ // log to ./.runcap, so we chdir into the temp dir before starting it.
20
+ const tmpHome = mkdtempSync(path.join(os.tmpdir(), "runcap-e2e-"));
21
+ process.chdir(tmpHome);
22
+ process.env.AIM_COMPRESS = "off"; // keep the wire bytes predictable
23
+ process.env.AIM_LOOP_DETECT = "on";
24
+
25
+ // A controllable upstream: returns an OpenAI-shaped completion whose assistant
26
+ // text is whatever error we tell it to via a field in the request body. We use
27
+ // the body (not a header) on purpose: the gateway forwards the request body
28
+ // upstream but rewrites headers, so the body is the channel that actually
29
+ // reaches this stub through the real gateway.
30
+ const upstream = http.createServer((req, res) => {
31
+ let body = "";
32
+ req.on("data", (c) => (body += c));
33
+ req.on("end", () => {
34
+ let err = "default error";
35
+ try { err = JSON.parse(body)?.mock_error ?? err; } catch {}
36
+ const payload = {
37
+ id: "chatcmpl-stub",
38
+ object: "chat.completion",
39
+ created: Math.floor(Date.now() / 1000),
40
+ model: "stub-model",
41
+ choices: [{ index: 0, message: { role: "assistant", content: String(err) }, finish_reason: "stop" }],
42
+ usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 }
43
+ };
44
+ res.writeHead(200, { "content-type": "application/json" });
45
+ res.end(JSON.stringify(payload));
46
+ });
47
+ });
48
+
49
+ async function listen(server, port = 0) {
50
+ await new Promise((r) => server.listen(port, "127.0.0.1", r));
51
+ return server.address().port;
52
+ }
53
+
54
+ let failures = 0;
55
+ function check(name, pass, detail) {
56
+ if (!pass) failures++;
57
+ console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
58
+ }
59
+
60
+ const stableTail = [
61
+ "You are a coding agent. Fix the failing build.",
62
+ ...Array.from({ length: 40 }, (_, i) => `context line ${i}: prior file content the agent keeps resending`)
63
+ ].join("\n");
64
+
65
+ async function send(port, wording, mockError) {
66
+ // mock_error rides in the body so it survives the gateway's header rewrite and
67
+ // reaches the upstream stub, which echoes it back as the assistant response.
68
+ const body = JSON.stringify({
69
+ model: "stub-model",
70
+ mock_error: mockError,
71
+ messages: [{ role: "user", content: stableTail + "\nLet me try this: " + wording }]
72
+ });
73
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json" },
76
+ body
77
+ });
78
+ await res.text();
79
+ }
80
+
81
+ function readEvents() {
82
+ const log = path.join(tmpHome, ".runcap", "gateway-events.jsonl");
83
+ if (!existsSync(log)) return [];
84
+ return readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
85
+ }
86
+
87
+ // Loop verdicts accumulate across both scenarios in one shared gateway process
88
+ // (the shape history is per-process), so each scenario asserts against only the
89
+ // events it produced. We snapshot the event count before scenario B.
90
+
91
+ const run = async () => {
92
+ const upstreamPort = await listen(upstream);
93
+ process.env.AIM_UPSTREAM_BASE_URL = `http://127.0.0.1:${upstreamPort}/v1`;
94
+ process.env.AIM_UPSTREAM_API_KEY = "test-key";
95
+
96
+ // Import AFTER env is set so the gateway reads our isolated config.
97
+ const { startEphemeralGateway } = await import("../src/mission-control.mjs");
98
+ const gw = await startEphemeralGateway();
99
+ const gwPort = gw.port;
100
+
101
+ // Scenario A: same prompt framing, but the error MOVES every turn (convergence).
102
+ for (const [w, e] of [
103
+ ["guard the undefined", "TypeError: cannot read property 'id' of undefined"],
104
+ ["optional chain", "TypeError: cannot read property 'name' of undefined"],
105
+ ["default to {}", "ReferenceError: parser is not defined"],
106
+ ["try/catch", "AssertionError: expected 200 but got 404"]
107
+ ]) {
108
+ await send(gwPort, w, e);
109
+ }
110
+ const afterA = readEvents();
111
+ const aFlagged = afterA.filter((ev) => ev.loop && ev.loop.looping).length;
112
+ check("E2E convergence (moving error) is NOT flagged through real gateway", aFlagged === 0,
113
+ `loops flagged in scenario A=${aFlagged}`);
114
+
115
+ // Scenario B: same prompt framing AND the SAME error every turn (circling).
116
+ const stuck = "TypeError: cannot read property 'id' of undefined";
117
+ for (const w of ["attempt one", "attempt two reworded", "attempt three reworded", "attempt four reworded", "attempt five reworded"]) {
118
+ await send(gwPort, w, stuck);
119
+ }
120
+ const afterB = readEvents().slice(afterA.length); // only scenario-B events
121
+ const bFlagged = afterB.filter((ev) => ev.loop && ev.loop.looping).length;
122
+ check("E2E circling (stuck error) IS flagged through real gateway", bFlagged > 0,
123
+ `loops flagged in scenario B=${bFlagged}`);
124
+
125
+ await gw.close();
126
+ upstream.close();
127
+ };
128
+
129
+ run()
130
+ .then(() => {
131
+ console.log("\n" + (failures === 0 ? "ALL LOOP E2E TESTS PASSED" : `${failures} LOOP E2E TEST(S) FAILED`));
132
+ process.exit(failures === 0 ? 0 : 1);
133
+ })
134
+ .catch((e) => {
135
+ console.error("E2E harness error:", e);
136
+ process.exit(1);
137
+ });
@@ -0,0 +1,128 @@
1
+ // Loop / circling detection tests, run against the REAL compressor exports.
2
+ // Proves the "looks productive but stuck" signal the gateway emits:
3
+ // 1. Reworded same-failure attempts (similar-but-not-identical prompts) are
4
+ // flagged as a loop once they repeat enough times.
5
+ // 2. Genuine progress (the conversation tail actually changing) is NOT flagged.
6
+ // 3. A single slow/long legit step is NOT flagged.
7
+ //
8
+ // Pure Node, no test framework. Exits non-zero on any failure so it can gate CI.
9
+
10
+ import { detectLoop, requestShapeText, responseSignature } from "../src/compressor.mjs";
11
+
12
+ let failures = 0;
13
+ function check(name, pass, detail) {
14
+ if (!pass) failures++;
15
+ console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
16
+ }
17
+
18
+ // A long, stable conversation tail (system + history the agent keeps resending),
19
+ // plus a final attempt line that the agent only REWORDS each loop. This is the
20
+ // exact case that fools cheap hashing: 99% identical, never byte-equal.
21
+ const stableTail = [
22
+ "You are a coding agent. Fix the failing build.",
23
+ ...Array.from({ length: 40 }, (_, i) => `context line ${i}: prior file content the agent keeps resending`),
24
+ "The test still fails with: TypeError: cannot read property 'id' of undefined"
25
+ ].join("\n");
26
+
27
+ function attempt(wording) {
28
+ return stableTail + "\n" + "Let me try this: " + wording;
29
+ }
30
+
31
+ // --- Test 1: reworded same-failure attempts are flagged as a loop ---
32
+ {
33
+ const history = [
34
+ attempt("guard the undefined with an if check"),
35
+ attempt("add an optional chain before .id"),
36
+ attempt("default the object to {} before reading id")
37
+ ];
38
+ const current = attempt("wrap the access in a try/catch and read id safely");
39
+ const r = detectLoop(current, history);
40
+ check("reworded same-failure attempts flagged as loop", r.looping && r.repeats >= 3,
41
+ `repeats=${r.repeats}, similarity=${r.similarity}`);
42
+ }
43
+
44
+ // --- Test 2: real progress is NOT flagged ---
45
+ // Each turn the conversation tail genuinely changes (new files, new errors).
46
+ {
47
+ const history = [
48
+ "Fix the build. Error: missing module 'parser'.\n" + "ctx A ".repeat(40),
49
+ "Installed parser. New error: parser.parse is not a function.\n" + "ctx B ".repeat(40)
50
+ ];
51
+ const current = "Fixed the call signature. Now the test passes; writing the next feature.\n" + "ctx C ".repeat(40);
52
+ const r = detectLoop(current, history);
53
+ check("genuine progress is NOT flagged as loop", !r.looping,
54
+ `looping=${r.looping}, repeats=${r.repeats}`);
55
+ }
56
+
57
+ // --- Test 3: a single slow/long legit step is NOT flagged ---
58
+ // One big request with no prior near-identical history must never trip.
59
+ {
60
+ const current = attempt("first and only attempt at this step");
61
+ const r = detectLoop(current, []);
62
+ check("single long step is NOT flagged", !r.looping && r.repeats === 0,
63
+ `repeats=${r.repeats}`);
64
+ }
65
+
66
+ // --- Test 4: two repeats is at_risk but below the warn threshold ---
67
+ {
68
+ const history = [attempt("try A"), attempt("try B")];
69
+ const current = attempt("try C");
70
+ const r = detectLoop(current, history);
71
+ check("two near-identical repeats not yet a loop (under threshold)", !r.looping && r.repeats === 2,
72
+ `repeats=${r.repeats}`);
73
+ }
74
+
75
+ // --- Test 5: requestShapeText pulls the same text from OpenAI and Anthropic shapes ---
76
+ {
77
+ const openai = requestShapeText({ messages: [{ role: "user", content: "hello world" }] });
78
+ const anthropic = requestShapeText({ messages: [{ role: "user", content: [{ type: "text", text: "hello world" }] }] });
79
+ check("requestShapeText normalizes OpenAI and Anthropic content", openai === "hello world" && anthropic === "hello world",
80
+ `openai="${openai}" anthropic="${anthropic}"`);
81
+ }
82
+
83
+ // --- Test 6: response-side gate — similar prompts but a MOVING error is NOT a loop ---
84
+ // The edge case raised on the thread: a converging run also sends near-identical
85
+ // prompts (same files, same framing) while it closes in on the fix. The tell is
86
+ // the observation: if the error/test output changes between turns, that's
87
+ // progress, not circling. Prompts are near-identical here, but each response
88
+ // carries a DIFFERENT error, so the gate must keep it from being flagged.
89
+ {
90
+ const history = [attempt("try A"), attempt("try B"), attempt("try C")];
91
+ const current = attempt("try D");
92
+ const responseSignatures = [
93
+ "TypeError: cannot read property 'id' of undefined",
94
+ "TypeError: cannot read property 'name' of undefined",
95
+ "ReferenceError: parser is not defined"
96
+ ];
97
+ const currentResponseSignature = "AssertionError: expected 200 but got 404";
98
+ const r = detectLoop(current, history, { responseSignatures, currentResponseSignature });
99
+ check("similar prompts but MOVING error are NOT flagged (convergence)", !r.looping && r.responseMoved,
100
+ `looping=${r.looping}, repeats=${r.repeats}, responseMoved=${r.responseMoved}`);
101
+ }
102
+
103
+ // --- Test 7: response-side gate — similar prompts AND a STUCK error IS a loop ---
104
+ // Same near-identical prompts, but the identical error keeps coming back. Now
105
+ // both signals agree the run is circling, so it must still be flagged.
106
+ {
107
+ const history = [attempt("try A"), attempt("try B"), attempt("try C")];
108
+ const current = attempt("try D");
109
+ const sameError = "TypeError: cannot read property 'id' of undefined";
110
+ const responseSignatures = [sameError, sameError, sameError];
111
+ const currentResponseSignature = sameError;
112
+ const r = detectLoop(current, history, { responseSignatures, currentResponseSignature });
113
+ check("similar prompts AND stuck error ARE flagged as loop", r.looping && !r.responseMoved && r.repeats >= 3,
114
+ `looping=${r.looping}, repeats=${r.repeats}, responseMoved=${r.responseMoved}`);
115
+ }
116
+
117
+ // --- Test 8: responseSignature extracts the error/text from both provider shapes ---
118
+ {
119
+ const openai = responseSignature({ choices: [{ message: { content: "boom: it failed" } }] });
120
+ const anthropic = responseSignature({ content: [{ type: "text", text: "boom: it failed" }] });
121
+ const errEnvelope = responseSignature({ error: { message: "rate limited" } });
122
+ check("responseSignature reads OpenAI, Anthropic, and error shapes",
123
+ openai === "boom: it failed" && anthropic === "boom: it failed" && errEnvelope === "rate limited",
124
+ `openai="${openai}" anthropic="${anthropic}" err="${errEnvelope}"`);
125
+ }
126
+
127
+ console.log("\n" + (failures === 0 ? "ALL LOOP TESTS PASSED" : `${failures} LOOP TEST(S) FAILED`));
128
+ process.exit(failures === 0 ? 0 : 1);
@@ -16,27 +16,28 @@ const C = {
16
16
 
17
17
  const lines = [
18
18
  { t: "$ runcap plan --fuel 24 -- \"build a small auth feature and verify it\"", c: C.prompt, at: 0.3 },
19
- { t: "Estimate: $3 - $7 (range, not an oracle)", c: C.text, at: 1.1 },
20
- { t: "Recommended cap: $10", c: C.ok, at: 1.5 },
21
- { t: "", c: C.text, at: 1.6 },
22
- { t: "$ ANTHROPIC_BASE_URL=http://127.0.0.1:8792/v1 \\", c: C.prompt, at: 2.2 },
23
- { t: " AIM_DAILY_BUDGET_USD=10 runcap gateway", c: C.prompt, at: 2.6 },
24
- { t: "gateway up · compression on · hard cap armed", c: C.dim, at: 3.2 },
25
- { t: "", c: C.text, at: 3.3 },
26
- { t: "→ request 10,144 tokens", c: C.text, at: 3.9 },
27
- { t: "→ compressed 1,260 tokens (JSON + logs trimmed, prose untouched)", c: C.ok, at: 4.6 },
28
- { t: "", c: C.text, at: 4.7 },
29
- { t: "You saved $7.40 · would have spent $18.40 · cap $10", c: C.accent, at: 5.4 },
30
- { t: "", c: C.text, at: 5.5 },
31
- { t: "→ next call would cross the ceiling", c: C.text, at: 6.1 },
32
- { t: "HTTP 429 budget_guard — run stopped before money left your account", c: C.bad, at: 6.8 }
19
+ { t: "Estimate: $3 - $7 (range, not an oracle)", c: C.text, at: 1.0 },
20
+ { t: "Recommended cap: $10", c: C.ok, at: 1.4 },
21
+ { t: "", c: C.text, at: 1.5 },
22
+ { t: "$ ANTHROPIC_BASE_URL=http://127.0.0.1:8792/v1 \\", c: C.prompt, at: 2.0 },
23
+ { t: " AIM_DAILY_BUDGET_USD=10 runcap gateway", c: C.prompt, at: 2.3 },
24
+ { t: "gateway up · compress on · hard cap armed · loop guard on", c: C.dim, at: 2.9 },
25
+ { t: "", c: C.text, at: 3.0 },
26
+ { t: "→ request 10,144 tokens", c: C.text, at: 3.5 },
27
+ { t: "→ compressed 1,260 tokens (1,186 737 on a real call: 37.9% saved)", c: C.ok, at: 4.1 },
28
+ { t: "", c: C.text, at: 4.2 },
29
+ { t: " loop: last 3 prompts 97.7% identical - agent circling the same fail", c: C.violet, at: 5.0 },
30
+ { t: " (looks busy, makes no progress, keeps spending)", c: C.dim, at: 5.5 },
31
+ { t: "", c: C.text, at: 5.6 },
32
+ { t: " next call would cross the ceiling", c: C.text, at: 6.2 },
33
+ { t: "HTTP 429 budget_guard - run stopped before money left your account", c: C.bad, at: 6.9 }
33
34
  ];
34
35
 
35
- const W = 920, H = 560;
36
+ const W = 920, H = 588;
36
37
  const padX = 28, top = 78, lh = 27, fs = 16.5;
37
38
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
38
39
 
39
- const total = 8.0; // loop length seconds
40
+ const total = 9.0; // loop length seconds
40
41
  const rows = lines.map((ln, i) => {
41
42
  const y = top + i * lh;
42
43
  // fade+slide in at ln.at, hold, then reset at end of loop
@@ -46,7 +47,7 @@ const rows = lines.map((ln, i) => {
46
47
  ${esc(ln.t)}</text>`;
47
48
  }).join("\n");
48
49
 
49
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Runcap terminal demo: plan, cap, compress, stop">
50
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Runcap terminal demo: plan, cap, compress, detect loop, stop">
50
51
  <defs>
51
52
  <linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
52
53
  <stop offset="0" stop-color="#22d3ee"/><stop offset="1" stop-color="#34d399"/>
@@ -64,7 +65,7 @@ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" wid
64
65
  <circle cx="26" cy="28" r="6" fill="#f87171"/>
65
66
  <circle cx="48" cy="28" r="6" fill="#fbbf24"/>
66
67
  <circle cx="70" cy="28" r="6" fill="#34d399"/>
67
- <text x="100" y="33" fill="#8a8a8a" font-family="'JetBrains Mono',monospace" font-size="14">runcap estimate · cap · compress · rescue</text>
68
+ <text x="100" y="33" fill="#8a8a8a" font-family="'JetBrains Mono',monospace" font-size="14">runcap · estimate · cap · compress · loop · rescue</text>
68
69
  <text x="${W-150}" y="33" fill="url(#brand)" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="15">run·cap</text>
69
70
  </g>
70
71
  <line x1="0" y1="50" x2="${W}" y2="50" stroke="#1c1c1f"/>