loki-mode 10.11.2 → 11.0.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.
Files changed (115) hide show
  1. package/.github/actions/issue-to-pr/action.yml +17 -0
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +1 -1
  5. package/autonomy/lib/workspace.py +1 -1
  6. package/autonomy/loki +5 -8
  7. package/bin/loki +21 -18
  8. package/dashboard/__init__.py +1 -1
  9. package/docs/AGENT-CHANGE-RECEIPT.md +47 -0
  10. package/docs/CLI-REFERENCE.md +1 -1
  11. package/docs/INSTALLATION.md +1 -1
  12. package/docs/v10/DEPS.md +1 -0
  13. package/docs/v10/FAILURE-CLASSES.md +8 -0
  14. package/docs/v10/GUIDE.md +17 -22
  15. package/docs/v10/PROGRESS.md +25 -0
  16. package/docs/v10/RELEASE-11.md +77 -0
  17. package/docs/v11/C1-multi-user-sso.md +30 -0
  18. package/docs/v11/C10-the-8090-teardown.md +28 -0
  19. package/docs/v11/C11-mcp-modern.md +16 -0
  20. package/docs/v11/C2-hosted-runner.md +30 -0
  21. package/docs/v11/C3-reproducible-environments.md +30 -0
  22. package/docs/v11/C4-bitbucket-azure.md +29 -0
  23. package/docs/v11/C5-open-local-models.md +29 -0
  24. package/docs/v11/C6-secrets-handling.md +31 -0
  25. package/docs/v11/C7-windows.md +30 -0
  26. package/docs/v11/C8-public-benchmark.md +30 -0
  27. package/docs/v11/C9-opt-in-telemetry.md +33 -0
  28. package/docs/v11/README.md +32 -0
  29. package/loki-ts/dist/loki.js +696 -682
  30. package/mcp/__init__.py +1 -1
  31. package/package.json +3 -2
  32. package/packages/control-plane/dist/ask-tools-server.js +41169 -0
  33. package/packages/control-plane/dist/server.js +2080 -980
  34. package/packages/control-plane/package.json +1 -1
  35. package/packages/control-plane/src/ask/invoke.ts +42 -0
  36. package/packages/control-plane/src/ask/policy.ts +65 -0
  37. package/packages/control-plane/src/ask/prompt.ts +38 -0
  38. package/packages/control-plane/src/ask/store.ts +88 -0
  39. package/packages/control-plane/src/ask/worker.ts +136 -0
  40. package/packages/control-plane/src/db/fixture-cleanup.ts +24 -10
  41. package/packages/control-plane/src/server/app.ts +13 -6
  42. package/packages/control-plane/src/server/ingest.ts +6 -1
  43. package/packages/control-plane/src/server/reconcile.ts +33 -0
  44. package/packages/control-plane/src/server/repos.ts +37 -3
  45. package/packages/control-plane/src/server/routes/ask.ts +139 -0
  46. package/packages/control-plane/src/server/routes/cost.ts +19 -0
  47. package/packages/control-plane/src/server/routes/doctor.ts +67 -0
  48. package/packages/control-plane/src/server/routes/index.ts +7 -3
  49. package/packages/control-plane/src/server/routes/stream.ts +22 -11
  50. package/packages/control-plane/src/server/runs.ts +27 -0
  51. package/packages/control-plane/src/server/spawn.ts +19 -6
  52. package/packages/control-plane/ui/dist/assets/index-CxZWgwie.css +1 -0
  53. package/packages/control-plane/ui/dist/assets/index-DcBJXjCJ.js +190 -0
  54. package/packages/control-plane/ui/dist/index.html +2 -2
  55. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  56. package/web-app/dist/assets/{AdminPage-CmgIgBFY.js → AdminPage-DT071AJt.js} +1 -1
  57. package/web-app/dist/assets/{Avatar-Dm3ypJzh.js → Avatar-BlrQe1qX.js} +1 -1
  58. package/web-app/dist/assets/{Badge-0siYUwAA.js → Badge-lTUHjeos.js} +1 -1
  59. package/web-app/dist/assets/{Button-EMKQXvZe.js → Button-GewN0tNc.js} +1 -1
  60. package/web-app/dist/assets/{CockpitPage-Z60eDvJ9.js → CockpitPage-DrO_gIsU.js} +1 -1
  61. package/web-app/dist/assets/{ComparePage-D4L7nViy.js → ComparePage-DONALZcu.js} +1 -1
  62. package/web-app/dist/assets/{ErrorBoundary--v9DugrJ.js → ErrorBoundary-C6ZKmMc3.js} +1 -1
  63. package/web-app/dist/assets/{EvidenceReceiptPanel-CA866XsM.js → EvidenceReceiptPanel-BKd4DLj6.js} +1 -1
  64. package/web-app/dist/assets/{GitHubIssuesPanel-Bi6FJmR2.js → GitHubIssuesPanel-A6lIpXXR.js} +1 -1
  65. package/web-app/dist/assets/{GitHubPRsPanel-CgSfcWOg.js → GitHubPRsPanel-pR1qVGFE.js} +1 -1
  66. package/web-app/dist/assets/{HomePage-BxQtk9Ec.js → HomePage-bWv4-4k6.js} +2 -2
  67. package/web-app/dist/assets/{LoginPage-CCSxWlFA.js → LoginPage-D15fkL9Y.js} +1 -1
  68. package/web-app/dist/assets/{MagicPage-B4eYm0jJ.js → MagicPage-i6SBuJQM.js} +1 -1
  69. package/web-app/dist/assets/{MetricsPage-Bp4Hh0p2.js → MetricsPage-CUmjtIoG.js} +1 -1
  70. package/web-app/dist/assets/{NotFoundPage-DTXk_JK4.js → NotFoundPage-ChmAJkSz.js} +1 -1
  71. package/web-app/dist/assets/{ProjectPage-6S1v0GHS.js → ProjectPage-CjNXA3Nw.js} +3 -3
  72. package/web-app/dist/assets/{ProjectsPage-9foqEwG2.js → ProjectsPage-Bz2RXP8-.js} +1 -1
  73. package/web-app/dist/assets/{SettingsPage-CWOG_hhD.js → SettingsPage-BOPmXS7a.js} +1 -1
  74. package/web-app/dist/assets/{ShowcasePage-Du9LYlao.js → ShowcasePage-DD7bQm3F.js} +1 -1
  75. package/web-app/dist/assets/{SystemSettingsPage-BQconaDX.js → SystemSettingsPage-CT3PgCUq.js} +1 -1
  76. package/web-app/dist/assets/{TeamsPage-_NsqtVmC.js → TeamsPage-BavvjzSJ.js} +1 -1
  77. package/web-app/dist/assets/{TemplatesPage-DyTEGBpR.js → TemplatesPage-DEG8i3uW.js} +1 -1
  78. package/web-app/dist/assets/{TerminalOutput-XHOYTpky.js → TerminalOutput-CBYzxa0B.js} +1 -1
  79. package/web-app/dist/assets/{activity-B9KiMdEs.js → activity-DlboSvUU.js} +1 -1
  80. package/web-app/dist/assets/{bell-Oe4lG6kK.js → bell-CJeJkmKT.js} +1 -1
  81. package/web-app/dist/assets/{bot-D86Qmxfy.js → bot-uzIrJnZD.js} +1 -1
  82. package/web-app/dist/assets/{check-BvW6_ni0.js → check-Bewu2Iay.js} +1 -1
  83. package/web-app/dist/assets/{chevron-left-DYA62-3g.js → chevron-left-DEysJM-t.js} +1 -1
  84. package/web-app/dist/assets/{circle-alert-CHW8ye4p.js → circle-alert-CPLcNoq8.js} +1 -1
  85. package/web-app/dist/assets/{clock-QbQ2XfbM.js → clock-ByRncQBl.js} +1 -1
  86. package/web-app/dist/assets/{cloud-ylzrY7eL.js → cloud-DHYxCPZZ.js} +1 -1
  87. package/web-app/dist/assets/{code-xml-DdKKw2Y9.js → code-xml-BuzWGPzB.js} +1 -1
  88. package/web-app/dist/assets/{database-BLY_OWs3.js → database-BWEwDMiP.js} +1 -1
  89. package/web-app/dist/assets/{dollar-sign-CsFIa1e2.js → dollar-sign-CGmUPUwE.js} +1 -1
  90. package/web-app/dist/assets/{file-code-corner-Df4nyrCW.js → file-code-corner-CDBuJZyI.js} +1 -1
  91. package/web-app/dist/assets/{file-plus-_MWEY2WY.js → file-plus-CdZblpEV.js} +1 -1
  92. package/web-app/dist/assets/{globe-Dpc7QU9m.js → globe-Cowwu6Ai.js} +1 -1
  93. package/web-app/dist/assets/{hammer-B-PldT0c.js → hammer-DnLoK-ns.js} +1 -1
  94. package/web-app/dist/assets/{index-gq0R3j-o.js → index-D2lNcBZ2.js} +3 -3
  95. package/web-app/dist/assets/{layers-BjcNwHLZ.js → layers-Rx4mi5c5.js} +1 -1
  96. package/web-app/dist/assets/{loader-circle-CoTgcqkV.js → loader-circle-DvFfixiL.js} +1 -1
  97. package/web-app/dist/assets/{lock-JEI8O6TS.js → lock-BBme3oKq.js} +1 -1
  98. package/web-app/dist/assets/{package-CXWkNS40.js → package-CMetrH6c.js} +1 -1
  99. package/web-app/dist/assets/{plus-CRK4U5b0.js → plus-DVVns7YI.js} +1 -1
  100. package/web-app/dist/assets/{refresh-cw-CrzWr4cw.js → refresh-cw-BwxSxRvP.js} +1 -1
  101. package/web-app/dist/assets/{rotate-ccw-DV7UZyUw.js → rotate-ccw-B3bQkZTK.js} +1 -1
  102. package/web-app/dist/assets/{scroll-text-CXEqRrXU.js → scroll-text-YtUOGO66.js} +1 -1
  103. package/web-app/dist/assets/{server-CDFfFP10.js → server-CtsspPXY.js} +1 -1
  104. package/web-app/dist/assets/{shield-alert-B5EC_m6_.js → shield-alert-DPXb3JfQ.js} +1 -1
  105. package/web-app/dist/assets/{thumbs-up-BeLDemd2.js → thumbs-up-BLCEqoJv.js} +1 -1
  106. package/web-app/dist/assets/{trash-2-B7kS86-0.js → trash-2-DzG9BsOq.js} +1 -1
  107. package/web-app/dist/assets/{trending-down-BGvuLayo.js → trending-down-DbcPLKOe.js} +1 -1
  108. package/web-app/dist/assets/{trending-up-BkPM2tiK.js → trending-up-B2QEVmFq.js} +1 -1
  109. package/web-app/dist/assets/{upload-CXVI0GRU.js → upload-CzVHQcht.js} +1 -1
  110. package/web-app/dist/assets/{usePolling-CMGMVsMA.js → usePolling-ceGuFnBH.js} +1 -1
  111. package/web-app/dist/assets/{user-DPcXNtfj.js → user-qYglqMMb.js} +1 -1
  112. package/web-app/dist/index.html +1 -1
  113. package/autonomy/lib/engine10-legacy-notice.txt +0 -3
  114. package/packages/control-plane/ui/dist/assets/index-BinL6h3i.css +0 -1
  115. package/packages/control-plane/ui/dist/assets/index-D27veKDf.js +0 -152
@@ -0,0 +1,139 @@
1
+ // CP-ASK slice 10: Ask Loki HTTP surface, behind LOKI_CP_ASK=1 (flag off: every /v1/ask* route is 404).
2
+ // Free text goes ONLY to the read-only Ask worker; it never reaches the run-start path (this file has no import from the run-start module).
3
+ // Gate: a bearer token (enforced by tokenGuard on /v1/*) or, without one, a loopback peer and Host. POST needs JSON, a same-host Origin and questions up to 4000 chars.
4
+ import { basename } from "node:path";
5
+ import type { Context } from "hono";
6
+ import { localRepos } from "../../db/schema.ts";
7
+ import { buildInvocation, MODEL_RE } from "../../ask/invoke.ts";
8
+ import { activeCount, addTurn, citations, createThread, getMessage, getThread, listEvents, listMessages, listThreads, markInterrupted, pidAlive, TERMINAL, threadBusy } from "../../ask/store.ts";
9
+ import { cancelJob, runAskJob } from "../../ask/worker.ts";
10
+ import { isLoopbackHost } from "../auth.ts";
11
+ import { audit } from "../audit.ts";
12
+ import type { RouteCtx } from "./index.ts";
13
+ import { createSse } from "./stream.ts";
14
+
15
+ export const MAX_QUESTION = 4000;
16
+ const PROVIDERS = new Set(["claude", "codex", "cline", "aider", "opencode"]); // opencode and cline and aider are refused by buildInvocation
17
+ const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
18
+ export const askEnabled = (): boolean => process.env.LOKI_CP_ASK === "1";
19
+
20
+
21
+ export function mount(ctx: RouteCtx): void {
22
+ const { app, db } = ctx;
23
+ // A restart leaves running rows with dead worker pids; flip them so the UI offers a retry.
24
+ markInterrupted(db, pidAlive);
25
+ const sse = createSse({ pollMs: num(process.env.LOKI_ASK_STREAM_POLL_MS, 300), heartbeatMs: num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000), maxStreams: num(process.env.LOKI_CONTROL_STREAM_MAX, 32) });
26
+ const off = (c: Context) => c.json({ error: "not found" }, 404);
27
+ const allowed = (c: Context) => !!ctx.token || (ctx.peerIsLoopback(c) && isLoopbackHost(c.req.header("host")));
28
+ const sameOrigin = (c: Context): boolean => {
29
+ const origin = c.req.header("origin");
30
+ if (origin === undefined) return true;
31
+ try { return new URL(origin).host.toLowerCase() === (c.req.header("host") ?? "").toLowerCase(); } catch { return false; }
32
+ };
33
+ const dbPath = (): string => {
34
+ const f = (db as unknown as { $client?: { filename?: string } }).$client?.filename;
35
+ return f && f !== ":memory:" ? f : process.env.LOKI_CONTROL_DB ?? "";
36
+ };
37
+ const knownRepo = (name: string): boolean =>
38
+ name === basename(ctx.repoDir) || db.select({ n: localRepos.name }).from(localRepos).all().some((r) => r.n === name);
39
+
40
+ // Every route: flag first (404), then the access gate (403).
41
+ const guard = (c: Context): Response | null => (!askEnabled() ? off(c) : !allowed(c) ? c.json({ error: "loopback or bearer token required" }, 403) : null);
42
+
43
+ app.post("/v1/ask", async (c) => {
44
+ const g = guard(c);
45
+ if (g) return g;
46
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 415);
47
+ if (!sameOrigin(c)) return c.json({ error: "origin not allowed" }, 403);
48
+ const text = await c.req.text();
49
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
50
+ let body: Record<string, unknown>;
51
+ try { const j = JSON.parse(text); if (!j || typeof j !== "object" || Array.isArray(j)) throw new Error(); body = j as Record<string, unknown>; } catch { return c.json({ error: "invalid JSON" }, 400); }
52
+ const question = typeof body.question === "string" ? body.question.trim() : "";
53
+ if (!question) return c.json({ error: "question must be a non-empty string" }, 400);
54
+ if (question.length > MAX_QUESTION) return c.json({ error: `question must be at most ${MAX_QUESTION} characters` }, 400);
55
+
56
+ let threadId: string;
57
+ let provider: string, model: string | null, repo: string | null;
58
+ if (body.thread_id !== undefined && body.thread_id !== null && body.thread_id !== "") {
59
+ const t = typeof body.thread_id === "string" ? getThread(db, body.thread_id) : undefined;
60
+ if (!t) return c.json({ error: "thread not found" }, 404);
61
+ if (threadBusy(db, t.id)) return c.json({ error: "this thread is still answering" }, 409);
62
+ threadId = t.id; provider = t.provider; model = t.model; repo = t.repo;
63
+ } else {
64
+ provider = typeof body.provider === "string" && body.provider ? body.provider : process.env.LOKI_ASK_PROVIDER || "claude";
65
+ if (!PROVIDERS.has(provider)) return c.json({ error: "provider must be one of: claude, codex" }, 400);
66
+ model = typeof body.model === "string" && body.model ? body.model : null;
67
+ if (model !== null && !MODEL_RE.test(model)) return c.json({ error: "model must match [A-Za-z0-9][A-Za-z0-9._/-]{0,79}" }, 400);
68
+ repo = typeof body.repo === "string" && body.repo ? body.repo : null;
69
+ if (repo !== null && !knownRepo(repo)) return c.json({ error: "repo is not a known project" }, 400);
70
+ threadId = "";
71
+ }
72
+ const dry = buildInvocation({ provider, model, mcpConfigPath: "", jobDir: "", maxUsd: 1 });
73
+ if (!dry.ok) return c.json({ error: dry.error }, 400);
74
+ if (activeCount(db) >= num(process.env.LOKI_ASK_MAX_JOBS, 2)) return c.json({ error: "too many Ask jobs running; try again shortly" }, 429, { "retry-after": "10" });
75
+
76
+ if (!threadId) threadId = createThread(db, { provider, model, repo });
77
+ const { assistantId } = addTurn(db, threadId, question);
78
+ audit(db, { kind: "ask.start", target: threadId, result: "queued", detail: `provider ${provider}; ${question.length} chars` });
79
+ void runAskJob(db, assistantId, {
80
+ toolsServer: process.env.LOKI_ASK_TOOLS_SERVER || undefined, dbPath: dbPath(), bin: process.env.LOKI_ASK_BIN || undefined,
81
+ timeoutMs: num(process.env.LOKI_ASK_TIMEOUT_S, 600) * 1000, maxUsd: num(process.env.LOKI_ASK_MAX_USD, 1),
82
+ }).catch(() => { /* runAskJob records its own failures on the row */ });
83
+ return c.json({ thread_id: threadId, message_id: assistantId }, 202);
84
+ });
85
+
86
+ app.get("/v1/ask/threads", (c) => {
87
+ const g = guard(c);
88
+ if (g) return g;
89
+ return c.json({ threads: listThreads(db).map((t) => ({ id: t.id, title: t.title, updated_at: t.updatedAt })) });
90
+ });
91
+
92
+ app.get("/v1/ask/threads/:id", (c) => {
93
+ const g = guard(c);
94
+ if (g) return g;
95
+ const t = getThread(db, c.req.param("id"));
96
+ if (!t) return c.json({ error: "thread not found" }, 404);
97
+ const messages = listMessages(db, t.id).map((m) => ({
98
+ id: m.id, seq: m.seq, role: m.role, text: m.text, status: m.status, error: m.error, cost_usd: m.costUsd,
99
+ ...(m.role === "assistant" ? { citations: citations(db, m.id) } : {}),
100
+ }));
101
+ return c.json({ thread: { id: t.id, title: t.title, repo: t.repo }, messages });
102
+ });
103
+
104
+ app.get("/v1/ask/messages/:id/stream", (c) => {
105
+ const g = guard(c);
106
+ if (g) return g;
107
+ const id = c.req.param("id");
108
+ if (!getMessage(db, id)) return c.json({ error: "message not found" }, 404);
109
+ const given = c.req.header("last-event-id") ?? c.req.query("after");
110
+ const parsed = given === undefined || given === "" ? NaN : Number(given);
111
+ let cursor = Number.isInteger(parsed) ? parsed : -1; // a fresh client replays the whole answer
112
+ const tick = (): string[] | { frames: string[]; end: true } => {
113
+ const m = getMessage(db, id)!;
114
+ const terminal = TERMINAL.has(m.status); // read status BEFORE events so no event is missed
115
+ const rows = listEvents(db, id, cursor);
116
+ if (rows.length) cursor = rows[rows.length - 1]!.seq;
117
+ const frames = rows.map((e) => `id: ${e.seq}\nevent: ${e.kind}\ndata: ${e.payload}\n\n`);
118
+ if (!terminal) return frames;
119
+ return { frames: [...frames, `event: done\ndata: ${JSON.stringify({ status: m.status, error: m.error })}\n\n`], end: true };
120
+ };
121
+ const first = tick();
122
+ if (!Array.isArray(first)) {
123
+ return new Response(`retry: 3000\n: connected\n\n${first.frames.join("")}`, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", "x-accel-buffering": "no" } });
124
+ }
125
+ return sse(c.req.raw.signal, `retry: 3000\n: connected\n\n${first.join("")}`, tick);
126
+ });
127
+
128
+ app.post("/v1/ask/messages/:id/cancel", async (c) => {
129
+ const g = guard(c);
130
+ if (g) return g;
131
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 415);
132
+ if (!sameOrigin(c)) return c.json({ error: "origin not allowed" }, 403);
133
+ const id = c.req.param("id");
134
+ if (!getMessage(db, id)) return c.json({ error: "message not found" }, 404);
135
+ if (!cancelJob(db, id)) return c.json({ error: "message is not running" }, 409);
136
+ audit(db, { kind: "ask.cancel", target: id, result: "cancelled", detail: "" });
137
+ return c.json({ ok: true });
138
+ });
139
+ }
@@ -3,6 +3,7 @@
3
3
  // Unmeasured = no priced session (subscription or local provider): never summed as 0.
4
4
  import { gte } from "drizzle-orm";
5
5
  import { runs } from "../../db/schema.ts";
6
+ import { effectiveVerdict, SUCCESS_VERDICTS } from "../integrity.ts";
6
7
  import type { RouteCtx } from "./index.ts";
7
8
 
8
9
  export const COST_DIMS = ["day", "model", "repo", "provider"] as const;
@@ -29,6 +30,24 @@ const round = (n: number) => Math.round(n * 1e6) / 1e6;
29
30
  const out = (a: Acc) => ({ ...a.key, runs: a.runs, measured_runs: a.measured_runs, partial_runs: a.partial_runs, unmeasured_runs: a.unmeasured_runs, measured_usd: round(a.measured_usd), partial_usd: round(a.partial_usd), input_tokens: a.input_tokens, output_tokens: a.output_tokens });
30
31
 
31
32
  export function mount(ctx: RouteCtx): void {
33
+ // B8: per-repo breakdown. total_usd sums measured runs only (never invents a zero); avg_usd is over measured runs; verified = plain attested success display verdict.
34
+ ctx.app.get("/v1/cost/repos", (c) => {
35
+ const g = new Map<string, { runs: number; priced: number; usd: number; verified: number }>();
36
+ for (const r of ctx.db.select().from(runs).all()) {
37
+ const k = r.originRepo ?? "(unknown)";
38
+ const a = g.get(k) ?? { runs: 0, priced: 0, usd: 0, verified: 0 };
39
+ a.runs++;
40
+ if (r.costUsd !== null) { a.priced++; a.usd += r.costUsd; }
41
+ const ev = effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 });
42
+ if (ev !== null && SUCCESS_VERDICTS.has(ev)) a.verified++;
43
+ g.set(k, a);
44
+ }
45
+ const rows = [...g.entries()].sort((x, y) => x[0].localeCompare(y[0])).map(([repo, a]) => ({
46
+ repo, runs: a.runs, total_usd: round(a.usd), avg_usd: a.priced ? round(a.usd / a.priced) : null,
47
+ verified_runs: a.verified, usd_per_verified: a.verified ? round(a.usd / a.verified) : null,
48
+ }));
49
+ return c.json({ rows });
50
+ });
32
51
  ctx.app.get("/v1/stats/cost", (c) => {
33
52
  const dims = (c.req.query("group") ?? "day").split(",").map((s) => s.trim()).filter(Boolean) as CostDim[];
34
53
  if (dims.length === 0 || dims.some((d) => !COST_DIMS.includes(d))) {
@@ -0,0 +1,67 @@
1
+ // GET /v1/doctor (A5): the real `loki doctor --json` checks, mapped to {name,status,detail}. Fixed argv, no shell, 25s timeout,
2
+ // the same scrubbed child env as spawned runs, cached for 60s. Unusable output is an error (502), never an invented pass.
3
+ import { spawn } from "node:child_process";
4
+ import { childEnv } from "../spawn.ts";
5
+ import { probeAllowed, ttlCache, type RouteCtx } from "./index.ts";
6
+
7
+ export const DOCTOR_TIMEOUT_MS = 25_000;
8
+ const MAX_OUT = 1_000_000;
9
+
10
+ export type DoctorStatus = "pass" | "warn" | "fail";
11
+ export interface DoctorCheck { name: string; status: DoctorStatus; detail: string }
12
+
13
+ const isStatus = (s: unknown): s is DoctorStatus => s === "pass" || s === "warn" || s === "fail";
14
+
15
+ /** Run `<bin> doctor --json` and return stdout. doctor exits 1 when a check fails, so the exit code is not the signal; the JSON is. */
16
+ export function runDoctor(bin: string, timeoutMs = DOCTOR_TIMEOUT_MS): Promise<string> {
17
+ return new Promise((resolve, reject) => {
18
+ let out = "";
19
+ let done = false;
20
+ let child: ReturnType<typeof spawn>;
21
+ const finish = (f: () => void): void => { if (!done) { done = true; clearTimeout(timer); f(); } };
22
+ try {
23
+ child = spawn(bin, ["doctor", "--json"], { stdio: ["ignore", "pipe", "ignore"], shell: false, env: childEnv() });
24
+ } catch (e) { return reject(new Error(`could not run loki doctor: ${(e as Error).message}`)); }
25
+ const timer = setTimeout(() => {
26
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
27
+ finish(() => reject(new Error(`loki doctor timed out after ${Math.round(timeoutMs / 1000)}s`)));
28
+ }, timeoutMs);
29
+ child.stdout?.on("data", (d: Buffer) => { if (out.length < MAX_OUT) out += d.toString("utf8"); });
30
+ child.on("error", (e) => finish(() => reject(new Error(`could not run loki doctor: ${e.message}`))));
31
+ child.on("close", () => finish(() => resolve(out)));
32
+ });
33
+ }
34
+
35
+ /** Map the doctor JSON (tool checks plus disk and AI provider) to the flat checklist shape. */
36
+ export function mapDoctor(raw: string): DoctorCheck[] {
37
+ let j: { checks?: unknown; disk?: unknown; ai_provider?: unknown };
38
+ try { j = JSON.parse(raw); } catch { throw new Error("loki doctor did not return JSON"); }
39
+ if (!j || !Array.isArray(j.checks)) throw new Error("loki doctor JSON has no checks");
40
+ const out: DoctorCheck[] = [];
41
+ for (const c of j.checks as Record<string, unknown>[]) {
42
+ if (typeof c?.name !== "string" || !isStatus(c.status)) continue;
43
+ const sev = typeof c.required === "string" ? c.required : "";
44
+ const detail = c.found === false
45
+ ? `not found${sev ? ` (${sev})` : ""}`
46
+ : `${typeof c.version === "string" ? c.version : "found"}${c.status === "warn" && typeof c.min_version === "string" ? `, needs ${c.min_version} or newer` : ""}`;
47
+ out.push({ name: c.name, status: c.status, detail });
48
+ }
49
+ const d = j.disk as { available_gb?: unknown; status?: unknown } | undefined;
50
+ if (d && isStatus(d.status)) out.push({ name: "Disk space", status: d.status, detail: typeof d.available_gb === "number" ? `${d.available_gb} GB available` : "unknown" });
51
+ const a = j.ai_provider as { found?: unknown; status?: unknown; detail?: unknown } | undefined;
52
+ if (a && isStatus(a.status)) out.push({ name: "AI provider", status: a.status, detail: typeof a.detail === "string" && a.detail ? a.detail : a.found ? "found" : "not found" });
53
+ if (out.length === 0) throw new Error("loki doctor returned no usable checks");
54
+ return out;
55
+ }
56
+
57
+ export function mount(ctx: RouteCtx): void {
58
+ const cached = ttlCache<{ checks: DoctorCheck[] }>();
59
+ ctx.app.get("/v1/doctor", async (c) => {
60
+ if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
61
+ try {
62
+ return c.json(await cached("doctor", async () => ({ checks: mapDoctor(await runDoctor(ctx.startBin ?? "loki")) })));
63
+ } catch (e) {
64
+ return c.json({ error: (e as Error).message }, 502);
65
+ }
66
+ });
67
+ }
@@ -4,14 +4,16 @@ import type { Context, Hono } from "hono";
4
4
  import type { Db } from "../../db/migrate.ts";
5
5
  import type { spawnStart } from "../spawn.ts";
6
6
  import { isLoopbackHost } from "../auth.ts";
7
- import { mountRepos } from "../repos.ts";
7
+ import { mountRepos, type GhRunner } from "../repos.ts";
8
8
  import { mount as artifacts } from "./artifacts.ts";
9
+ import { mount as ask } from "./ask.ts";
9
10
  import { mount as audit } from "./audit.ts";
10
11
  import { mount as checkpoints } from "./checkpoints.ts";
11
12
  import { mount as config } from "./config.ts";
12
13
  import { mount as control } from "./control.ts";
13
14
  import { mount as cost } from "./cost.ts";
14
15
  import { mount as costLedger } from "./cost_ledger.ts";
16
+ import { mount as doctor } from "./doctor.ts";
15
17
  import { mount as fleet } from "./fleet.ts";
16
18
  import { mount as memory } from "./memory.ts";
17
19
  import { mount as metrics } from "./metrics.ts";
@@ -41,12 +43,14 @@ export interface RouteCtx {
41
43
  spawnImpl?: typeof spawnStart;
42
44
  /** Where BLOCKED answers are written (createApp answerDir). */
43
45
  answerDir?: string;
46
+ /** gh seam for GET /v1/repos/issues (tests stub it). */
47
+ ghImpl?: GhRunner;
44
48
  }
45
49
 
46
- export const routeModules: ReadonlyArray<(ctx: RouteCtx) => void> = [artifacts, stream, start, control, stats, cost, costLedger, fleet, metrics, checkpoints, memory, sessionControl, config, providers, verify, integrations, notify, audit, mergeRisk];
50
+ export const routeModules: ReadonlyArray<(ctx: RouteCtx) => void> = [artifacts, ask, stream, start, control, stats, cost, costLedger, doctor, fleet, metrics, checkpoints, memory, sessionControl, config, providers, verify, integrations, notify, audit, mergeRisk];
47
51
 
48
52
  export function registerRoutes(ctx: RouteCtx): void {
49
- mountRepos(ctx.act, ctx.db, ctx.peerIsLoopback, ctx.repoDir);
53
+ mountRepos(ctx.act, ctx.db, ctx.peerIsLoopback, ctx.repoDir, ctx.ghImpl);
50
54
  for (const m of routeModules) m(ctx);
51
55
  }
52
56
 
@@ -7,19 +7,17 @@ import type { RouteCtx } from "./index.ts";
7
7
 
8
8
  const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
9
9
 
10
- export function mount(ctx: RouteCtx): void {
11
- const pollMs = num(process.env.LOKI_CONTROL_STREAM_POLL_MS, 1000);
12
- const heartbeatMs = num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000);
13
- const maxStreams = num(process.env.LOKI_CONTROL_STREAM_MAX, 32);
14
- const { db } = ctx;
10
+ /** A tick returns frames to send, or frames plus end:true to send them and close the stream. */
11
+ export type SseTick = () => string[] | { frames: string[]; end: true };
12
+
13
+ /** Bounded SSE responder (shared by the run streams and the Ask stream). Each instance has its own slot count; a slot is freed exactly once on abort, cancel, end or error. */
14
+ export function createSse(opts: { pollMs: number; heartbeatMs: number; maxStreams: number }) {
15
15
  const enc = new TextEncoder();
16
16
  let open = 0;
17
-
18
17
  const headers = { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" };
19
18
 
20
- /** Open one SSE response. tick() runs each poll and returns frames to send; the slot is freed exactly once on abort, cancel or error. */
21
- const sse = (signal: AbortSignal, hello: string, tick: () => string[]): Response => {
22
- if (open >= maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
19
+ return (signal: AbortSignal, hello: string, tick: SseTick): Response => {
20
+ if (open >= opts.maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
23
21
  open++;
24
22
  let freed = false;
25
23
  let poll: ReturnType<typeof setInterval> | undefined;
@@ -30,14 +28,27 @@ export function mount(ctx: RouteCtx): void {
30
28
  const send = (s: string) => { try { ctrl.enqueue(enc.encode(s)); } catch { free(); } };
31
29
  const close = () => { free(); try { ctrl.close(); } catch { /* already closed */ } };
32
30
  send(hello);
33
- poll = setInterval(() => { try { for (const f of tick()) send(f); } catch { close(); } }, pollMs);
34
- beat = setInterval(() => send(": heartbeat\n\n"), heartbeatMs);
31
+ poll = setInterval(() => {
32
+ try {
33
+ const r = tick();
34
+ if (Array.isArray(r)) { for (const f of r) send(f); } else { for (const f of r.frames) send(f); close(); }
35
+ } catch { close(); }
36
+ }, opts.pollMs);
37
+ beat = setInterval(() => send(": heartbeat\n\n"), opts.heartbeatMs);
35
38
  if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true });
36
39
  },
37
40
  cancel: free,
38
41
  });
39
42
  return new Response(body, { headers });
40
43
  };
44
+ }
45
+
46
+ export function mount(ctx: RouteCtx): void {
47
+ const pollMs = num(process.env.LOKI_CONTROL_STREAM_POLL_MS, 1000);
48
+ const heartbeatMs = num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000);
49
+ const maxStreams = num(process.env.LOKI_CONTROL_STREAM_MAX, 32);
50
+ const { db } = ctx;
51
+ const sse = createSse({ pollMs, heartbeatMs, maxStreams });
41
52
 
42
53
  const maxSeq = (source: string, run: string): number =>
43
54
  db.select({ m: sql<number | null>`max(${events.seq})` }).from(events).where(and(eq(events.sourceId, source), eq(events.runId, run))).get()?.m ?? -1;
@@ -143,6 +143,30 @@ export function listRuns(db: Db, q: ListQuery) {
143
143
  return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
144
144
  }
145
145
 
146
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]|\[\d{1,3}(?:;\d{1,3})*m/g;
147
+ const oneLine = (s: string, max: number) => s.replace(ANSI, "").replace(/[\x00-\x1f\x7f\s]+/g, " ").trim().slice(0, max);
148
+ // FC-19: Loki's own stage rules talked the model into a false spec conflict. The engine records no version or classifier, so this reads the model's own words.
149
+ const OWN_RULES = /\bstage rules\b|\bstage-rules\b/i;
150
+
151
+ /** The terminal stop reason as one ANSI-free sentence, from the run's own end (never from verify output); null when the run did not stop short. */
152
+ export function stopReason(evs: EventEnvelope[], verdict: string | null): { reason: string | null; ownRules: boolean } {
153
+ if (verdict === "SPEC_CONFLICT") {
154
+ const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
155
+ const text = typeof why === "string" ? oneLine(why, 4000) : "";
156
+ const first = text.split(/(?<=[.!?])\s+(?=[A-Z])/)[0] ?? "";
157
+ const reason = `The run stopped on a spec conflict: ${first ? first.slice(0, 240) : "the task and the repository disagree"}`;
158
+ return { reason: /[.!?]$/.test(reason) ? reason : `${reason}.`, ownRules: OWN_RULES.test(text) };
159
+ }
160
+ if (verdict === "FAILED") {
161
+ const failed = [...evs].reverse().find((e) => e.type === "stage.failed");
162
+ const why = typeof failed?.data?.reason === "string" ? oneLine(failed.data.reason as string, 240) : "";
163
+ if (failed && failed.stage) return { reason: `The run failed in the ${failed.stage} stage${why ? `: ${why}` : ""}.`, ownRules: false };
164
+ const bad = evs.filter((e) => e.type === "test.result" && (e.data as Record<string, unknown>).result === "fail").length;
165
+ return { reason: bad ? `The run failed: ${bad} check${bad === 1 ? "" : "s"} did not pass.` : "The run failed and did not record a reason.", ownRules: false };
166
+ }
167
+ return { reason: null, ownRules: false };
168
+ }
169
+
146
170
  /** Summary row plus the folded detail: stage timeline, receipt, not_proven. Null when the run is unknown. */
147
171
  export function runDetail(db: Db, sourceId: string, runId: string) {
148
172
  const r = db.select().from(runs).where(and(eq(runs.sourceId, sourceId), eq(runs.runId, runId))).get();
@@ -164,9 +188,12 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
164
188
  const f = fold(evs);
165
189
  const done = f.run.completed?.data;
166
190
  const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
191
+ const stop = stopReason(evs, r.verdict);
167
192
  return {
168
193
  ...withLive(db, r),
169
194
  blocked_question: blockedQuestion(evs, r.verdict),
195
+ stop_reason: stop.reason,
196
+ own_rules_block: stop.ownRules,
170
197
  stages,
171
198
  stages_completed: f.completed,
172
199
  receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
@@ -1,8 +1,8 @@
1
1
  // Start-a-run planning and spawning for the UI (CPE-07). A run is started by spawning the same `loki start` path an operator would type (argv array, no shell).
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { basename, join, resolve } from "node:path";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
6
 
7
7
  const ISSUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,8}$/;
8
8
  // A free-text task: letters, digits, spaces and a small punctuation set. No colon, quotes, backticks, $, ;, &, |, <, >, parens, braces, backslash or control chars.
@@ -22,6 +22,17 @@ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
22
  const BUDGET = /^(?:[1-9][0-9]{0,5}|0)(?:\.[0-9]{1,2})?$/;
23
23
  const WORKSPACE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
24
24
 
25
+ /** A run may only start in a git repo, never in HOME or "/" (a "whats going on" composer message once started a coding run in the home directory). Null = allowed. */
26
+ export function repoRefusal(cwd: string, env: NodeJS.ProcessEnv = process.env): string | null {
27
+ const real = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } };
28
+ const dir = real(cwd);
29
+ const home = real(env.HOME || homedir());
30
+ if (dir === "/" || dir === resolve("/")) return "refusing to start a run in the filesystem root; pick a git repository";
31
+ if (dir === home) return "refusing to start a run in your home directory; pick a project repository (a folder with its own .git)";
32
+ for (let d = dir; d !== "/" && d !== home && d !== dirname(d); d = dirname(d)) if (existsSync(join(d, ".git"))) return null;
33
+ return `${cwd} is not a git repository; pick a registered project repository`;
34
+ }
35
+
25
36
  export type StartPlan = { ok: true; argv: string[]; cwd: string; env: Record<string, string> } | { ok: false; error: string };
26
37
 
27
38
  /** Registered project paths from ~/.loki/dashboard/projects.json (the `loki projects` registry), existing directories only. */
@@ -33,7 +44,7 @@ export function registryRepos(env: NodeJS.ProcessEnv = process.env): string[] {
33
44
  }
34
45
 
35
46
  /** Validate a start request. The repo must be one the server already knows (registry or cwd), never an arbitrary path. */
36
- export function planStart(body: unknown, known: string[], bin = "loki"): StartPlan {
47
+ export function planStart(body: unknown, known: string[], bin = "loki", env: NodeJS.ProcessEnv = process.env): StartPlan {
37
48
  const b = (body && typeof body === "object" ? body : {}) as { target?: unknown; repo?: unknown; model?: unknown; provider?: unknown; budget?: unknown; workspace?: unknown };
38
49
  if (typeof b.target !== "string") return { ok: false, error: "target must be a string" };
39
50
  const target = b.target.trim();
@@ -51,6 +62,8 @@ export function planStart(body: unknown, known: string[], bin = "loki"): StartPl
51
62
  if (!hit) return { ok: false, error: named.length > 1 ? "repo name is ambiguous" : "repo is not a known project" };
52
63
  cwd = hit;
53
64
  }
65
+ const refusal = repoRefusal(cwd, env);
66
+ if (refusal) return { ok: false, error: refusal };
54
67
  const opt = (v: unknown): string | undefined => (v === undefined || v === null || v === "" ? undefined : typeof v === "number" && Number.isFinite(v) ? String(v) : typeof v === "string" ? v : "\0bad");
55
68
  const provider = opt(b.provider), model = opt(b.model), budget = opt(b.budget), workspace = opt(b.workspace);
56
69
  if (provider !== undefined && !PROVIDERS.has(provider)) return { ok: false, error: "provider must be one of: " + [...PROVIDERS].join(", ") };
@@ -58,11 +71,11 @@ export function planStart(body: unknown, known: string[], bin = "loki"): StartPl
58
71
  if (budget !== undefined && (!BUDGET.test(budget) || Number(budget) <= 0)) return { ok: false, error: "budget must be a positive USD amount such as 5 or 5.00" };
59
72
  if (workspace !== undefined && !WORKSPACE.test(workspace)) return { ok: false, error: "workspace must be a workspace name from loki.yaml (letters, digits, _ -)" };
60
73
  if (workspace !== undefined && (provider !== undefined || budget !== undefined)) return { ok: false, error: "workspace runs do not take provider or budget; set them per repo in loki.yaml" };
61
- const env: Record<string, string> = model !== undefined ? { LOKI_SESSION_MODEL: model } : {};
74
+ const runEnv: Record<string, string> = model !== undefined ? { LOKI_SESSION_MODEL: model } : {};
62
75
  // A workspace run is `loki workspace run <name> <ref>` (the existing multi-repo path). Otherwise free text goes as an explicit brief, never auto-detected as a PRD path.
63
- if (workspace !== undefined) return { ok: true, argv: [bin, "workspace", "run", workspace, target], cwd, env };
76
+ if (workspace !== undefined) return { ok: true, argv: [bin, "workspace", "run", workspace, target], cwd, env: runEnv };
64
77
  const flags = [...(provider !== undefined ? ["--provider", provider] : []), ...(budget !== undefined ? ["--budget", budget] : [])];
65
- return { ok: true, argv: issue ? [bin, "start", target, ...flags] : [bin, "start", "--brief", target, ...flags], cwd, env };
78
+ return { ok: true, argv: issue ? [bin, "start", target, ...flags] : [bin, "start", "--brief", target, ...flags], cwd, env: runEnv };
66
79
  }
67
80
 
68
81
  /** Spawn detached with no shell; resolves with the pid once the process exists, or an error if it cannot be launched. */
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap";:root,[data-theme=light]{--cp-bg: #F2EEE7;--cp-bg-2: #EBE6DC;--cp-bg-3: #E4DFD4;--cp-card: #FFFEFB;--cp-hover: #EBE6DC;--cp-glass: rgba(255,254,251,.82);--cp-glass-border: rgba(32,21,21,.08);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);--cp-text: #201515;--cp-text-2: #36342E;--cp-text-muted: #6B675C;--cp-text-inverse: #ffffff;--cp-border: #E2DDD2;--cp-border-light: #ECEAE3;--cp-accent: #553DE9;--cp-accent-hover: #4432c4;--cp-accent-active: #3828a0;--cp-accent-glow: rgba(85,61,233,.15);--cp-accent-muted: rgba(85,61,233,.1);--cp-success: #1f8a52;--cp-warning: #9a6a12;--cp-error: #b23a3a;--cp-info: #2F71E3;--cp-success-fill: #1FC5A8;--cp-success-muted: rgba(31,197,168,.12);--cp-warning-fill: #D4A03C;--cp-warning-muted: rgba(212,160,60,.12);--cp-error-fill: #C45B5B;--cp-error-muted: rgba(196,91,91,.12);--cp-info-muted: rgba(47,113,227,.12);--cp-accent-solid: #553DE9;--cp-accent-ink: #553DE9;--cp-error-ink: #b23a3a;--cp-success-ink: #1a7546;--cp-warning-ink: #895e10;--cp-info-ink: #2963c8;--cp-text-subtle: #5f5b52;--cp-opus: #d97706;--cp-sonnet: #553DE9;--cp-haiku: #1FC5A8;--cp-shadow-sm: 0 1px 2px rgba(32,21,21,.04);--cp-shadow-md: 0 4px 6px rgba(32,21,21,.06);--cp-shadow-lg: 0 10px 15px rgba(32,21,21,.08);--cp-focus: 0 0 0 3px rgba(85,61,233,.25)}[data-theme=dark]{--cp-bg: #0F0B16;--cp-bg-2: #17121F;--cp-bg-3: #211A2C;--cp-card: #17121F;--cp-hover: #211A2C;--cp-glass: rgba(15,11,22,.82);--cp-glass-border: rgba(255,255,255,.08);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.12);--cp-text: #F6F2EA;--cp-text-2: #CFC9BD;--cp-text-muted: #968F84;--cp-text-inverse: #0F0B16;--cp-border: rgba(255,255,255,.08);--cp-border-light: rgba(255,255,255,.04);--cp-accent: #8b7bf5;--cp-accent-hover: #9c8ff7;--cp-accent-active: #6258D0;--cp-accent-glow: rgba(139,123,245,.2);--cp-accent-muted: rgba(123,107,240,.18);--cp-success: #2ED8B6;--cp-warning: #E8B84A;--cp-error: #E07070;--cp-info: #5A9CF5;--cp-success-fill: #2ED8B6;--cp-success-muted: rgba(46,216,182,.18);--cp-warning-fill: #E8B84A;--cp-warning-muted: rgba(232,184,74,.18);--cp-error-fill: #E07070;--cp-error-muted: rgba(224,112,112,.18);--cp-info-muted: rgba(90,156,245,.18);--cp-success-ink: #2ED8B6;--cp-warning-ink: #E8B84A;--cp-info-ink: #6DA8F6;--cp-accent-solid: #6a55f2;--cp-accent-ink: #9c8ef6;--cp-error-ink: #E58989;--cp-text-subtle: #968F84;--cp-opus: #f59e0b;--cp-sonnet: #8b7bf5;--cp-haiku: #2ED8B6;--cp-shadow-sm: 0 1px 2px rgba(0,0,0,.4);--cp-shadow-md: 0 4px 12px rgba(0,0,0,.5);--cp-shadow-lg: 0 10px 25px rgba(0,0,0,.6);--cp-focus: 0 0 0 3px rgba(123,107,240,.3)}@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--cp-bg: #0F0B16;--cp-bg-2: #17121F;--cp-bg-3: #211A2C;--cp-card: #17121F;--cp-hover: #211A2C;--cp-glass: rgba(15,11,22,.82);--cp-glass-border: rgba(255,255,255,.08);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.12);--cp-text: #F6F2EA;--cp-text-2: #CFC9BD;--cp-text-muted: #968F84;--cp-text-inverse: #0F0B16;--cp-border: rgba(255,255,255,.08);--cp-border-light: rgba(255,255,255,.04);--cp-accent: #8b7bf5;--cp-accent-hover: #9c8ff7;--cp-accent-active: #6258D0;--cp-accent-glow: rgba(139,123,245,.2);--cp-accent-muted: rgba(123,107,240,.18);--cp-success: #2ED8B6;--cp-warning: #E8B84A;--cp-error: #E07070;--cp-info: #5A9CF5;--cp-success-fill: #2ED8B6;--cp-success-muted: rgba(46,216,182,.18);--cp-warning-fill: #E8B84A;--cp-warning-muted: rgba(232,184,74,.18);--cp-error-fill: #E07070;--cp-error-muted: rgba(224,112,112,.18);--cp-info-muted: rgba(90,156,245,.18);--cp-success-ink: #2ED8B6;--cp-warning-ink: #E8B84A;--cp-info-ink: #6DA8F6;--cp-accent-solid: #6a55f2;--cp-accent-ink: #9c8ef6;--cp-error-ink: #E58989;--cp-text-subtle: #968F84;--cp-opus: #f59e0b;--cp-sonnet: #8b7bf5;--cp-haiku: #2ED8B6;--cp-shadow-sm: 0 1px 2px rgba(0,0,0,.4);--cp-shadow-md: 0 4px 12px rgba(0,0,0,.5);--cp-shadow-lg: 0 10px 25px rgba(0,0,0,.6);--cp-focus: 0 0 0 3px rgba(123,107,240,.3)}}:root{--cp-font-serif: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;--cp-font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;--cp-font-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, monospace;--cp-text-xs: 10px;--cp-text-sm: 11px;--cp-text-base: 12px;--cp-text-md: 13px;--cp-text-lg: 14px;--cp-text-xl: 16px;--cp-text-2xl: 18px;--cp-text-3xl: 24px;--cp-space-xs: 4px;--cp-space-sm: 8px;--cp-space-md: 12px;--cp-space-lg: 16px;--cp-space-xl: 24px;--cp-space-2xl: 32px;--cp-space-3xl: 48px;--cp-radius-sm: 6px;--cp-radius-md: 12px;--cp-radius-lg: 16px;--cp-radius-nav: 9999px;--cp-radius-full: 9999px;--cp-ease: cubic-bezier(.4,0,.2,1);--cp-dur-fast: .1s;--cp-dur: .2s;--cp-dur-slow: .3s;--cp-sidebar-w: 264px;--cp-detail-w: 288px;--cp-rail-w: 48px;--cp-main-pad: 28px 32px;--cp-thread-max: 820px;--cp-term-bg: #0B0B0B;--cp-term-bar: #111111;--cp-term-side: #0D0D0D;--cp-term-line: #1D1D1D;--cp-term-card: #101010;--cp-term-text: #E8E4DA;--cp-term-strong: #F6F2EA;--cp-term-muted: #968F84;--cp-term-dim: #7A716A;--cp-term-purple: #7E74EE;--cp-term-lilac: #C8BEFF;--cp-term-green: #26C39B;--cp-term-amber: #F0B84A;--cp-term-red: #F07A7A;--cp-z-dropdown: 100;--cp-z-sticky: 200;--cp-z-modal: 300;--cp-z-popover: 400;--cp-z-toast: 600}.cp-root{background:var(--cp-bg);color:var(--cp-text);font-family:var(--cp-font-sans);font-size:var(--cp-text-md);line-height:1.5;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11"}@keyframes cp-pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes cp-spin{to{transform:rotate(360deg)}}@keyframes cp-fade-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}.cp-fade-in{animation:cp-fade-in var(--cp-dur) var(--cp-ease)}.cp-pulse{animation:cp-pulse 2s var(--cp-ease) infinite}.cp-spinner{animation:cp-spin .8s linear infinite}@media(prefers-reduced-motion:reduce){.cp-fade-in,.cp-pulse,.cp-spinner{animation:none}}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--cp-font-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--cp-font-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.min-h-11{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-0{min-width:0px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-400{--tw-border-opacity: 1;border-color:rgb(251 191 36 / var(--tw-border-opacity, 1))}.border-red-500\/50{border-color:#ef444480}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500\/15{background-color:#f59e0b26}.bg-emerald-500\/15{background-color:#10b98126}.bg-red-500\/15{background-color:#ef444426}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-600\/20{background-color:#dc262633}.bg-sky-500\/15{background-color:#0ea5e926}.bg-sky-600{--tw-bg-opacity: 1;background-color:rgb(2 132 199 / var(--tw-bg-opacity, 1))}.bg-sky-700{--tw-bg-opacity: 1;background-color:rgb(3 105 161 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:var(--cp-font-mono)}.font-sans{font-family:var(--cp-font-sans)}.font-serif{font-family:var(--cp-font-serif)}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-inverse{color:var(--cp-text-inverse)}.text-muted{color:var(--cp-text-muted)}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.accent-hover{accent-color:var(--cp-hover)}.accent-muted{accent-color:var(--cp-text-muted)}.shadow-lg{--tw-shadow: var(--cp-shadow-lg);--tw-shadow-colored: var(--cp-shadow-lg);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: var(--cp-shadow-md);--tw-shadow-colored: var(--cp-shadow-md);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: var(--cp-shadow-sm);--tw-shadow-colored: var(--cp-shadow-sm);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-amber-500\/30{--tw-ring-color: rgb(245 158 11 / .3)}.ring-emerald-500\/30{--tw-ring-color: rgb(16 185 129 / .3)}.ring-red-500\/30{--tw-ring-color: rgb(239 68 68 / .3)}.ring-red-600\/50{--tw-ring-color: rgb(220 38 38 / .5)}.ring-sky-500\/30{--tw-ring-color: rgb(14 165 233 / .3)}.ring-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}body{margin:0;background:var(--cp-bg);color:var(--cp-text);font-family:var(--cp-font-sans)}.cp-term{background:var(--cp-term-bg);color:var(--cp-term-text);border:1px solid #1A1A1A;border-radius:20px;overflow:hidden;box-shadow:0 40px 80px -40px #0a0a0a80}.cp-term-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 20px;background:var(--cp-term-bar);border-bottom:1px solid var(--cp-term-line);font-family:var(--cp-font-mono);font-size:12px;color:var(--cp-term-muted)}.cp-term-body{display:grid;grid-template-columns:1fr;min-height:320px}@media(min-width:768px){.cp-term-body{grid-template-columns:220px minmax(0,1fr)}}.cp-term-side{padding:16px;background:var(--cp-term-side);border-bottom:1px solid var(--cp-term-line);font-family:var(--cp-font-mono);font-size:12px}@media(min-width:768px){.cp-term-side{border-bottom:0;border-right:1px solid var(--cp-term-line)}}.cp-term-main{padding:20px 24px;min-width:0}.cp-term-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 24px;border-top:1px solid var(--cp-term-line);font-family:var(--cp-font-mono);font-size:11px;color:var(--cp-term-muted)}.cp-tl-row{display:grid;grid-template-columns:64px 96px minmax(0,1fr);gap:4px 12px;padding:8px 0;border-bottom:1px solid var(--cp-term-line);font-size:12px;line-height:1.55}.cp-tl-meta{grid-column:3;display:flex;flex-wrap:wrap;gap:4px 14px;font-family:var(--cp-font-mono);font-size:11px;color:var(--cp-term-muted)}.cp-trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.cp-eyebrow{font-family:var(--cp-font-mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--cp-text-muted)}.cp-display{font-family:var(--cp-font-sans);font-weight:650;letter-spacing:-.025em;line-height:1.12}.cp-pill-btn{border-radius:9999px}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is([data-theme=dark] *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is([data-theme=dark] *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-amber-950:is([data-theme=dark] *){--tw-bg-opacity: 1;background-color:rgb(69 26 3 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-800:is([data-theme=dark] *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is([data-theme=dark] *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:text-amber-200:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.dark\:text-amber-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:flex{display:flex}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-end{align-items:flex-end}}@media(min-width:768px){.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:hidden{display:none}.md\:min-h-0{min-height:0px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-6{padding:1.5rem}.md\:px-3{padding-left:.75rem;padding-right:.75rem}.md\:py-0{padding-top:0;padding-bottom:0}.md\:py-1{padding-top:.25rem;padding-bottom:.25rem}}