loki-mode 10.7.1 → 10.8.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.
Files changed (111) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/loki +4 -1
  4. package/autonomy/run.sh +9 -5
  5. package/dashboard/__init__.py +1 -1
  6. package/dashboard/server.py +6 -8
  7. package/docs/INSTALLATION.md +1 -1
  8. package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
  9. package/docs/v10/FAILURE-CLASSES.md +21 -0
  10. package/docs/v10/METRICS.md +6 -0
  11. package/loki-ts/data/model-pricing.json +6 -6
  12. package/loki-ts/dist/loki.js +800 -807
  13. package/loki-ts/package.json +1 -1
  14. package/mcp/__init__.py +1 -1
  15. package/package.json +2 -2
  16. package/packages/control-plane/dist/server.js +17090 -5672
  17. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  18. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  19. package/packages/control-plane/drizzle/meta/_journal.json +9 -2
  20. package/packages/control-plane/package.json +4 -1
  21. package/packages/control-plane/src/db/schema.ts +19 -0
  22. package/packages/control-plane/src/server/app.ts +11 -22
  23. package/packages/control-plane/src/server/audit.ts +15 -0
  24. package/packages/control-plane/src/server/auth.ts +7 -1
  25. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  26. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  27. package/packages/control-plane/src/server/legacy/shim.ts +195 -0
  28. package/packages/control-plane/src/server/repos.ts +27 -0
  29. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  30. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  31. package/packages/control-plane/src/server/routes/config.ts +319 -0
  32. package/packages/control-plane/src/server/routes/control.ts +75 -0
  33. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  34. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  35. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  36. package/packages/control-plane/src/server/routes/index.ts +68 -0
  37. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  38. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  39. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  40. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  41. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  42. package/packages/control-plane/src/server/routes/start.ts +39 -0
  43. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  44. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  45. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  46. package/packages/control-plane/src/server/spawn.ts +79 -0
  47. package/packages/control-plane/src/shipper/discover.ts +19 -1
  48. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  49. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  50. package/packages/control-plane/ui/dist/index.html +2 -2
  51. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  52. package/web-app/dist/assets/{AdminPage-BJSU5hY1.js → AdminPage-DDDvUz0N.js} +1 -1
  53. package/web-app/dist/assets/{Avatar-CSOSpw9c.js → Avatar-Dcdz46i6.js} +1 -1
  54. package/web-app/dist/assets/{Badge-COr7vj0D.js → Badge-BB24cCpK.js} +1 -1
  55. package/web-app/dist/assets/{Button-DGoWFERp.js → Button-D0-DmPZL.js} +1 -1
  56. package/web-app/dist/assets/{CockpitPage-CBpWkNXY.js → CockpitPage-CdjpU2JW.js} +1 -1
  57. package/web-app/dist/assets/{ComparePage-BwT8vqrL.js → ComparePage-DT5U0y0d.js} +1 -1
  58. package/web-app/dist/assets/{ErrorBoundary-D7SicDDA.js → ErrorBoundary-lEHcl0EO.js} +1 -1
  59. package/web-app/dist/assets/{EvidenceReceiptPanel-bx-5liG2.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
  60. package/web-app/dist/assets/{GitHubIssuesPanel-BAeeWLqY.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
  61. package/web-app/dist/assets/{GitHubPRsPanel-CssD8lGI.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
  62. package/web-app/dist/assets/{HomePage-SL5PT3m5.js → HomePage-PqRisOny.js} +2 -2
  63. package/web-app/dist/assets/{LoginPage-C8hFOZOM.js → LoginPage-DU7pFAkf.js} +1 -1
  64. package/web-app/dist/assets/{MagicPage-xHbHkrPf.js → MagicPage-C5JWIWMk.js} +1 -1
  65. package/web-app/dist/assets/{MetricsPage-B_2CKFcC.js → MetricsPage-CbIGS_qo.js} +1 -1
  66. package/web-app/dist/assets/{NotFoundPage-CXwXMq35.js → NotFoundPage-vU1pvdSt.js} +1 -1
  67. package/web-app/dist/assets/{ProjectPage-AaisMQI-.js → ProjectPage-SsixIPPv.js} +3 -3
  68. package/web-app/dist/assets/{ProjectsPage-Cej5ResX.js → ProjectsPage-BfQKSR9k.js} +1 -1
  69. package/web-app/dist/assets/{SettingsPage-DKv4mn9H.js → SettingsPage-B8YfXAom.js} +1 -1
  70. package/web-app/dist/assets/{ShowcasePage-Dkiyzne4.js → ShowcasePage-Ba407f2-.js} +1 -1
  71. package/web-app/dist/assets/{SystemSettingsPage-DaBLQ5dU.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
  72. package/web-app/dist/assets/{TeamsPage-B2yUr31k.js → TeamsPage-tqUnpZ15.js} +1 -1
  73. package/web-app/dist/assets/{TemplatesPage-CfYiANhU.js → TemplatesPage-ClB5EpI3.js} +1 -1
  74. package/web-app/dist/assets/{TerminalOutput-CMXoexLM.js → TerminalOutput-CBvpYfEu.js} +1 -1
  75. package/web-app/dist/assets/{activity-B0dzoZtY.js → activity-C1s0L_X9.js} +1 -1
  76. package/web-app/dist/assets/{bell-DFOTaSoo.js → bell-DMEiXr73.js} +1 -1
  77. package/web-app/dist/assets/{bot-Dz-WSQyP.js → bot-BF_xIOxu.js} +1 -1
  78. package/web-app/dist/assets/{check-L1QIRueJ.js → check-Dv1Ynvrs.js} +1 -1
  79. package/web-app/dist/assets/{chevron-left-1TrnIEZS.js → chevron-left-Ddhsod7S.js} +1 -1
  80. package/web-app/dist/assets/{circle-alert-8Bf6xEDx.js → circle-alert-OATHUkBZ.js} +1 -1
  81. package/web-app/dist/assets/{clock-DB8NE6P5.js → clock-C1Ltva07.js} +1 -1
  82. package/web-app/dist/assets/{cloud-DUSL3wZS.js → cloud-B-S4oOy9.js} +1 -1
  83. package/web-app/dist/assets/{code-xml-DbjryWNl.js → code-xml-D3Femn_F.js} +1 -1
  84. package/web-app/dist/assets/{database-DrezrMSs.js → database-DBSeFgO3.js} +1 -1
  85. package/web-app/dist/assets/{dollar-sign-CGbaLMzR.js → dollar-sign-sgDgNuGE.js} +1 -1
  86. package/web-app/dist/assets/{file-code-corner-Dfypa-r4.js → file-code-corner-C-MbdZEc.js} +1 -1
  87. package/web-app/dist/assets/{file-plus-CzV5L2Gd.js → file-plus-CCB6yqXv.js} +1 -1
  88. package/web-app/dist/assets/{globe-BoYjTwPQ.js → globe-DlbrNAj1.js} +1 -1
  89. package/web-app/dist/assets/{hammer-WO-HuiyJ.js → hammer-B-DWm695.js} +1 -1
  90. package/web-app/dist/assets/{index-CjIOUjnd.js → index-Dvny-1SU.js} +3 -3
  91. package/web-app/dist/assets/{layers-Bn6jtKNk.js → layers-99qsKpOT.js} +1 -1
  92. package/web-app/dist/assets/{loader-circle-gERWRnmW.js → loader-circle-ZvZyZl3J.js} +1 -1
  93. package/web-app/dist/assets/{lock-BIiMLJq8.js → lock-DosGjcxP.js} +1 -1
  94. package/web-app/dist/assets/{package-dLvDSVys.js → package-C-PumesN.js} +1 -1
  95. package/web-app/dist/assets/{plus-BHM3ZLhZ.js → plus-CTYYskTB.js} +1 -1
  96. package/web-app/dist/assets/{refresh-cw-BAp_alv6.js → refresh-cw-qW2ztTFS.js} +1 -1
  97. package/web-app/dist/assets/{rotate-ccw-CBhwq_MG.js → rotate-ccw-ZIKrf-vO.js} +1 -1
  98. package/web-app/dist/assets/{scroll-text-DoS-6dfU.js → scroll-text-BamlXjAT.js} +1 -1
  99. package/web-app/dist/assets/{server-5JgdDWQO.js → server-BljXb79I.js} +1 -1
  100. package/web-app/dist/assets/{shield-alert-DjmlLT34.js → shield-alert-Dr6eT9c_.js} +1 -1
  101. package/web-app/dist/assets/{thumbs-up-BtiEXl3p.js → thumbs-up-v3oBXZgU.js} +1 -1
  102. package/web-app/dist/assets/{trash-2-ClsWU_Vg.js → trash-2-BOuIrCpi.js} +1 -1
  103. package/web-app/dist/assets/{trending-down-DPS4M7_X.js → trending-down-Dp7jMCgH.js} +1 -1
  104. package/web-app/dist/assets/{trending-up-D6OJ38-V.js → trending-up-CDiur-nP.js} +1 -1
  105. package/web-app/dist/assets/{upload-DOCzLUpF.js → upload-DxIHXhNE.js} +1 -1
  106. package/web-app/dist/assets/{usePolling-CljQTWj_.js → usePolling-D2Uu-YZb.js} +1 -1
  107. package/web-app/dist/assets/{user-C0G82Weu.js → user-BVWCUAfF.js} +1 -1
  108. package/web-app/dist/index.html +1 -1
  109. package/packages/control-plane/src/server/actions.ts +0 -60
  110. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +0 -1
  111. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +0 -129
@@ -0,0 +1,85 @@
1
+ // CPE-05: Server-Sent Events for one run's new events and for runs-list changes.
2
+ // Driven by the store: a short poll of the events table sees rows from ingest (and so from the shipper watcher) with no coupling to the writer.
3
+ // Auth: /v1/* is already behind tokenGuard in app.ts. Streams are bounded; a disconnect frees its slot.
4
+ import { and, asc, eq, gt, sql } from "drizzle-orm";
5
+ import { events, runs } from "../../db/schema.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
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;
15
+ const enc = new TextEncoder();
16
+ let open = 0;
17
+
18
+ const headers = { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" };
19
+
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" } });
23
+ open++;
24
+ let freed = false;
25
+ let poll: ReturnType<typeof setInterval> | undefined;
26
+ let beat: ReturnType<typeof setInterval> | undefined;
27
+ const free = () => { if (freed) return; freed = true; clearInterval(poll); clearInterval(beat); open--; };
28
+ const body = new ReadableStream<Uint8Array>({
29
+ start(ctrl) {
30
+ const send = (s: string) => { try { ctrl.enqueue(enc.encode(s)); } catch { free(); } };
31
+ const close = () => { free(); try { ctrl.close(); } catch { /* already closed */ } };
32
+ send(hello);
33
+ poll = setInterval(() => { try { for (const f of tick()) send(f); } catch { close(); } }, pollMs);
34
+ beat = setInterval(() => send(": heartbeat\n\n"), heartbeatMs);
35
+ if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true });
36
+ },
37
+ cancel: free,
38
+ });
39
+ return new Response(body, { headers });
40
+ };
41
+
42
+ const maxSeq = (source: string, run: string): number =>
43
+ db.select({ m: sql<number | null>`max(${events.seq})` }).from(events).where(and(eq(events.sourceId, source), eq(events.runId, run))).get()?.m ?? -1;
44
+
45
+ ctx.app.get("/v1/runs/:source/:run/stream", (c) => {
46
+ const source = c.req.param("source"), run = c.req.param("run");
47
+ if (!db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get()) return c.json({ error: "run not found" }, 404);
48
+ // Resume point: Last-Event-ID (reconnect) or ?after=; a fresh client with neither gets only events from now on.
49
+ const given = c.req.header("last-event-id") ?? c.req.query("after");
50
+ const parsed = given === undefined || given === "" ? NaN : Number(given);
51
+ let cursor = Number.isInteger(parsed) ? parsed : maxSeq(source, run);
52
+ const tick = (): string[] => {
53
+ const rows = db.select().from(events).where(and(eq(events.sourceId, source), eq(events.runId, run), gt(events.seq, cursor))).orderBy(asc(events.seq)).limit(200).all();
54
+ if (rows.length) cursor = rows[rows.length - 1]!.seq;
55
+ return rows.map((e) => `id: ${e.seq}\nevent: event\ndata: ${JSON.stringify({ run: e.runId, seq: e.seq, ts: e.ts, type: e.type, stage: e.stage, data: e.data })}\n\n`);
56
+ };
57
+ const first = tick(); // replay for a resuming client is flushed with the hello
58
+ return sse(c.req.raw.signal, `retry: 3000\n: connected\n\n${first.join("")}`, tick);
59
+ });
60
+
61
+ ctx.app.get("/v1/stream", (c) => {
62
+ const snapshot = (): Map<string, string> => {
63
+ const m = new Map<string, string>();
64
+ for (const e of db.select({ s: events.sourceId, r: events.runId, m: sql<number>`max(${events.seq})` }).from(events).groupBy(events.sourceId, events.runId).all()) m.set(`${e.s}:${e.r}`, String(e.m));
65
+ for (const r of db.select({ s: runs.sourceId, r: runs.runId, v: runs.verdict, e: runs.endedAt }).from(runs).all()) {
66
+ const k = `${r.s}:${r.r}`;
67
+ m.set(k, `${m.get(k) ?? ""}|${r.v ?? ""}|${r.e ?? ""}`);
68
+ }
69
+ return m;
70
+ };
71
+ let seen = snapshot();
72
+ const tick = (): string[] => {
73
+ const now = snapshot();
74
+ const out: string[] = [];
75
+ for (const [k, sig] of now) {
76
+ if (seen.get(k) === sig) continue;
77
+ const i = k.indexOf(":");
78
+ out.push(`event: run\ndata: ${JSON.stringify({ source: k.slice(0, i), run: k.slice(i + 1) })}\n\n`);
79
+ }
80
+ seen = now;
81
+ return out;
82
+ };
83
+ return sse(c.req.raw.signal, "retry: 3000\n: connected\n\n", tick);
84
+ });
85
+ }
@@ -0,0 +1,66 @@
1
+ // Receipt verify and public key (CPE-16). Calls the engine's own verifyReceipt in process (loki-ts verify_cmd.ts), never reimplements the crypto.
2
+ // Read-only: it reads <repo>/.loki/runs/<run>/receipt.json (and events.jsonl beside it) and writes nothing. Loopback-only (on `act`), same path containment as the artifacts route.
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { createPublicKey } from "node:crypto";
5
+ import { join, sep } from "node:path";
6
+ import { and, eq } from "drizzle-orm";
7
+ import { localRepos, runs } from "../../db/schema.ts";
8
+ import { verifyReceipt } from "../../../../../loki-ts/src/engine10/verify_cmd.ts";
9
+ import { kidOf, loadSigningKey } from "../../../../../loki-ts/src/engine10/stages/seal.ts";
10
+ import { outcomeOf, runIdGuard } from "../../../../../loki-ts/src/features/receipt_dsse.ts";
11
+ import { audit } from "../audit.ts";
12
+ import type { RouteCtx } from "./index.ts";
13
+
14
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const safeId = (s: string) => ID.test(s) && !s.includes("..");
16
+
17
+ export function mount(ctx: RouteCtx): void {
18
+ const { act, db } = ctx;
19
+
20
+ act.post("/v1/runs/:source/:run/verify", async (c) => {
21
+ const source = c.req.param("source"), run = c.req.param("run");
22
+ const target = `${source}/${run}`;
23
+ const done = (status: 403 | 404, error: string, result: string) => { audit(db, { kind: "receipt.verify", target, result, detail: error }); return c.json({ error }, status); };
24
+ if (!ctx.local(c)) return done(403, "loopback only", "refused");
25
+ if (!safeId(source) || !safeId(run)) return done(404, "run not found", "not_found");
26
+ const known = db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get();
27
+ if (!known) return done(404, "run not found", "not_found");
28
+ const repo = db.select({ p: localRepos.realpath }).from(localRepos).where(eq(localRepos.sourceId, source)).get();
29
+ if (!repo) return done(404, "receipt not available", "not_found");
30
+ let runDir: string, file: string;
31
+ try {
32
+ const root = realpathSync(join(repo.p, ".loki", "runs"));
33
+ runDir = realpathSync(join(root, run));
34
+ if (runDir !== join(root, run)) return done(404, "receipt not found", "not_found");
35
+ file = realpathSync(join(runDir, "receipt.json"));
36
+ } catch { return done(404, "receipt not found", "not_found"); }
37
+ if (!file.startsWith(runDir + sep)) return done(404, "receipt not found", "not_found");
38
+ // Same order as `loki verify <run-id>`: the envelope must describe this run, then integrity, then the run outcome.
39
+ let integrity: string, reasons: string[], sha: string | null = null, outcome = "UNREADABLE";
40
+ try {
41
+ const bad = runIdGuard(file, run);
42
+ if (bad) { integrity = "TAMPERED"; reasons = [bad]; }
43
+ else { const r = await verifyReceipt(file); integrity = r.verdict; reasons = r.reasons; sha = r.receiptSha256 ?? null; }
44
+ try { outcome = outcomeOf(JSON.parse(readFileSync(file, "utf8"))); } catch { outcome = "UNREADABLE"; }
45
+ } catch (e) {
46
+ integrity = "UNCHECKED"; reasons = [`verifier error: ${(e as Error).message}`];
47
+ }
48
+ // Intact does not mean passing: an intact receipt of a run that did not verify is NOT_VERIFIED, as in the CLI (exit 4).
49
+ const intact = integrity === "VERIFIED" || integrity === "UNSIGNED";
50
+ const passing = outcome === "VERIFIED" || outcome === "ALREADY_SATISFIED";
51
+ let verdict = integrity;
52
+ if (intact && !passing) { verdict = "NOT_VERIFIED"; reasons = [`run outcome ${outcome}; receipt integrity ${integrity === "UNSIGNED" ? "unattested" : "intact"}`, ...reasons]; }
53
+ audit(db, { kind: "receipt.verify", target, result: verdict.toLowerCase(), detail: reasons[0] });
54
+ return c.json({ run, verdict, integrity, outcome, reasons, receipt_sha256: sha, verified_at: new Date().toISOString() });
55
+ });
56
+
57
+ // The public half of the receipt signer as a JWK. The private key never leaves loadSigningKey.
58
+ act.get("/v1/keys", (c) => {
59
+ if (!ctx.peerIsLoopback(c)) { audit(db, { kind: "keys.read", result: "refused", detail: "loopback only" }); return c.json({ error: "loopback only" }, 403); }
60
+ const priv = loadSigningKey(false);
61
+ if (!priv) { audit(db, { kind: "keys.read", result: "no_key" }); return c.json({ error: "no signing key on this machine (run a sealed build first or set LOKI_RECEIPT_SIGNING_KEY_FILE)" }, 404); }
62
+ const pub = createPublicKey(priv), { kty, crv, x } = pub.export({ format: "jwk" });
63
+ audit(db, { kind: "keys.read", result: "ok" });
64
+ return c.json({ kty, crv, x, kid: kidOf(pub), alg: "EdDSA", use: "sig" });
65
+ });
66
+ }
@@ -0,0 +1,79 @@
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
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
6
+
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
+ // A free-text task: letters, digits, spaces and a small punctuation set. No colon, quotes, backticks, $, ;, &, |, <, >, parens, braces, backslash or control chars.
9
+ const TASK = /^[A-Za-z0-9][A-Za-z0-9 .,_/#@+=-]{0,499}$/;
10
+ const hasDotDot = (t: string): boolean => t.split(/[\\/ ]/).some((seg) => seg.startsWith(".."));
11
+
12
+ /** Child env: the server's own secrets and bind config never reach a spawned run. */
13
+ export function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
14
+ const e: NodeJS.ProcessEnv = { ...env, LOKI_NO_BROWSER: "1" };
15
+ for (const k of ["LOKI_CONTROL_TOKEN", "LOKI_CONTROL_DB", "LOKI_CONTROL_HOST", "PORT"]) delete e[k];
16
+ return e;
17
+ }
18
+
19
+ // Optional run fields, each mapped to one known loki flag (or env var) and validated against an allowlist. Nothing else is accepted.
20
+ const PROVIDERS = new Set(["claude", "codex", "cline", "aider", "opencode"]);
21
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
+ const BUDGET = /^(?:[1-9][0-9]{0,5}|0)(?:\.[0-9]{1,2})?$/;
23
+ const WORKSPACE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
24
+
25
+ export type StartPlan = { ok: true; argv: string[]; cwd: string; env: Record<string, string> } | { ok: false; error: string };
26
+
27
+ /** Registered project paths from ~/.loki/dashboard/projects.json (the `loki projects` registry), existing directories only. */
28
+ export function registryRepos(env: NodeJS.ProcessEnv = process.env): string[] {
29
+ try {
30
+ const j = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
31
+ return Object.values(j.projects ?? {}).map((p) => (typeof p?.path === "string" ? p.path : "")).filter((p) => p && existsSync(p) && statSync(p).isDirectory());
32
+ } catch { return []; }
33
+ }
34
+
35
+ /** 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 {
37
+ const b = (body && typeof body === "object" ? body : {}) as { target?: unknown; repo?: unknown; model?: unknown; provider?: unknown; budget?: unknown; workspace?: unknown };
38
+ if (typeof b.target !== "string") return { ok: false, error: "target must be a string" };
39
+ const target = b.target.trim();
40
+ const issue = ISSUE.test(target);
41
+ if (!issue && !TASK.test(target)) return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
42
+ if (hasDotDot(target)) return { ok: false, error: "target must not contain a .. path segment" };
43
+ let cwd = process.cwd();
44
+ if (b.repo !== undefined && b.repo !== "") {
45
+ if (typeof b.repo !== "string" || b.repo.includes("\0")) return { ok: false, error: "repo must be a path string" };
46
+ // GET /v1/repos exposes display names only, so a repo may be given as a known path or as the unique display name of a known project.
47
+ const want = resolve(b.repo);
48
+ const paths = known.map((k) => resolve(k));
49
+ const named = paths.filter((k) => basename(k) === b.repo);
50
+ const hit = paths.find((k) => k === want) ?? (named.length === 1 ? named[0] : undefined);
51
+ if (!hit) return { ok: false, error: named.length > 1 ? "repo name is ambiguous" : "repo is not a known project" };
52
+ cwd = hit;
53
+ }
54
+ 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
+ const provider = opt(b.provider), model = opt(b.model), budget = opt(b.budget), workspace = opt(b.workspace);
56
+ if (provider !== undefined && !PROVIDERS.has(provider)) return { ok: false, error: "provider must be one of: " + [...PROVIDERS].join(", ") };
57
+ if (model !== undefined && !MODEL.test(model)) return { ok: false, error: "model must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}" };
58
+ 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
+ if (workspace !== undefined && !WORKSPACE.test(workspace)) return { ok: false, error: "workspace must be a workspace name from loki.yaml (letters, digits, _ -)" };
60
+ 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 } : {};
62
+ // 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 };
64
+ 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 };
66
+ }
67
+
68
+ /** Spawn detached with no shell; resolves with the pid once the process exists, or an error if it cannot be launched. */
69
+ export function spawnStart(argv: string[], cwd: string, onExit: () => void = () => {}, extraEnv: Record<string, string> = {}): Promise<{ pid: number } | { error: string }> {
70
+ return new Promise((done) => {
71
+ try {
72
+ const [cmd, ...args] = argv;
73
+ const child = spawn(cmd!, args, { cwd, detached: true, stdio: "ignore", shell: false, env: { ...childEnv(), ...extraEnv } });
74
+ child.once("exit", onExit);
75
+ child.once("error", (e) => { onExit(); done({ error: `could not start loki: ${e.message}` }); });
76
+ child.once("spawn", () => { child.unref(); done({ pid: child.pid ?? 0 }); });
77
+ } catch (e) { done({ error: `could not start loki: ${(e as Error).message}` }); }
78
+ });
79
+ }
@@ -2,9 +2,11 @@
2
2
  // ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
3
3
  // within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
4
4
  import { execFileSync } from "node:child_process";
5
- import { readFileSync, realpathSync } from "node:fs";
5
+ import { readFileSync, realpathSync, statSync } from "node:fs";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { basename, dirname, join, resolve } from "node:path";
8
+ import { sourceId } from "./ship.ts";
9
+ import { registryRepos } from "./watch.ts";
8
10
 
9
11
  export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
10
12
 
@@ -16,6 +18,22 @@ export function isLoopbackHttp(u: string): boolean {
16
18
  try { const p = new URL(u); return p.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(p.hostname); } catch { return false; }
17
19
  }
18
20
 
21
+ export interface LocalRepo { sourceId: string; realpath: string; name: string }
22
+
23
+ /** Local repo discovery for the server: its own repo plus every registered project (existing directories only). Purely local; nothing here comes from /v1/ingest. */
24
+ export function discoverLocalRepos(repoDir: string, env: NodeJS.ProcessEnv = process.env): LocalRepo[] {
25
+ const out = new Map<string, LocalRepo>();
26
+ for (const dir of [repoDir, ...registryRepos(env)]) {
27
+ try {
28
+ const real = realpathSync(dir);
29
+ if (!statSync(real).isDirectory()) continue;
30
+ const id = sourceId(real);
31
+ if (!out.has(id)) out.set(id, { sourceId: id, realpath: real, name: basename(real) });
32
+ } catch { /* a vanished or unreadable path is simply not a repo */ }
33
+ }
34
+ return [...out.values()];
35
+ }
36
+
19
37
  export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts = {}): Promise<string | null> {
20
38
  if (env.LOKI_CONTROL === "0") return null;
21
39
  if (env.LOKI_CONTROL_URL) return env.LOKI_CONTROL_URL;
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap";:root,[data-theme=light]{--cp-bg: #F1F2F6;--cp-bg-2: #E6E8EE;--cp-bg-3: #DBDEE6;--cp-card: rgba(255,255,255,.86);--cp-hover: #E6E8EE;--cp-glass: rgba(255,255,255,.7);--cp-glass-border: rgba(255,255,255,.4);--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: #4A4640;--cp-text-muted: #8A857C;--cp-text-inverse: #ffffff;--cp-border: rgba(0,0,0,.08);--cp-border-light: rgba(0,0,0,.05);--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: #6a665f;--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: #17161C;--cp-bg-2: #1E1D25;--cp-bg-3: #27262F;--cp-card: rgba(35,34,43,.82);--cp-hover: #27262F;--cp-glass: rgba(23,22,28,.7);--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: #F0ECF8;--cp-text-2: #B8B0C8;--cp-text-muted: #8B85A0;--cp-text-inverse: #17161C;--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: #8B85A0;--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: #17161C;--cp-bg-2: #1E1D25;--cp-bg-3: #27262F;--cp-card: rgba(35,34,43,.82);--cp-hover: #27262F;--cp-glass: rgba(23,22,28,.7);--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: #F0ECF8;--cp-text-2: #B8B0C8;--cp-text-muted: #8B85A0;--cp-text-inverse: #17161C;--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: #8B85A0;--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: "Fraunces", Georgia, "Times New Roman", 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: 2px;--cp-radius-md: 4px;--cp-radius-lg: 5px;--cp-radius-nav: 8px;--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: 240px;--cp-detail-w: 288px;--cp-rail-w: 48px;--cp-main-pad: 28px 32px;--cp-thread-max: 820px;--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}.mb-6{margin-bottom:1.5rem}.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-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-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-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)}.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-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-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-row{flex-direction:row}.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}}