loki-mode 10.6.7 → 10.6.9

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 (113) hide show
  1. package/README.md +44 -9
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +167 -36
  5. package/autonomy/lib/workspace.py +384 -0
  6. package/autonomy/lib/workspace_comment.py +91 -0
  7. package/autonomy/lib/worktree_prep.py +143 -0
  8. package/autonomy/loki +96 -3
  9. package/bin/loki +3 -3
  10. package/completions/_loki +2 -0
  11. package/completions/loki.bash +1 -2
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/api_start.py +26 -1
  14. package/docs/INSTALLATION.md +1 -1
  15. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  16. package/docs/WORKSPACES.md +36 -0
  17. package/docs/alternative-installations.md +3 -1
  18. package/docs/control-plane-container.md +50 -0
  19. package/docs/dashboard-guide.md +4 -0
  20. package/docs/dev/architecture-reference.md +4 -0
  21. package/docs/dev/project-structure.md +1 -1
  22. package/docs/exit-codes.md +1 -0
  23. package/docs/slack.md +34 -0
  24. package/docs/trackers.md +27 -0
  25. package/docs/v10/BOARD.md +74 -14
  26. package/docs/v10/CONTROL-PLANE.md +17 -6
  27. package/docs/v10/D51-PHASE-B.md +3 -1
  28. package/docs/v10/D61-SPEED.md +11 -2
  29. package/docs/v10/D63-CARDS.md +354 -0
  30. package/docs/v10/DECISIONS.md +63 -0
  31. package/docs/v10/DEPS.md +6 -2
  32. package/docs/v10/E2E-D65.md +87 -0
  33. package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
  34. package/docs/v10/PROGRESS.md +63 -0
  35. package/docs/v10/ROADMAP-D63.md +35 -0
  36. package/loki-ts/dist/loki.js +612 -556
  37. package/mcp/__init__.py +1 -1
  38. package/mcp/server.py +76 -0
  39. package/mcp/tests/test_v10_tools.py +193 -0
  40. package/mcp/v10_tools.py +182 -0
  41. package/package.json +1 -1
  42. package/packages/control-plane/dist/server.js +131 -9
  43. package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
  44. package/packages/control-plane/drizzle/meta/_journal.json +8 -1
  45. package/packages/control-plane/src/db/schema.ts +3 -0
  46. package/packages/control-plane/src/server/answer.ts +33 -0
  47. package/packages/control-plane/src/server/app.ts +22 -2
  48. package/packages/control-plane/src/server/auth.ts +51 -0
  49. package/packages/control-plane/src/server/runs.ts +6 -3
  50. package/packages/control-plane/src/server/serve.ts +13 -3
  51. package/packages/control-plane/src/shipper/discover.ts +29 -0
  52. package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
  53. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -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-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
  57. package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
  58. package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
  59. package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
  60. package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
  61. package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
  62. package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
  63. package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
  64. package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
  65. package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
  66. package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
  67. package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
  68. package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
  69. package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
  70. package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
  71. package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
  72. package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
  73. package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
  74. package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
  75. package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
  76. package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
  77. package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
  78. package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
  79. package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
  80. package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
  81. package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
  82. package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
  83. package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
  84. package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
  85. package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
  86. package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
  87. package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
  88. package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
  89. package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
  90. package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
  91. package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
  92. package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
  93. package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
  94. package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
  95. package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
  96. package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
  97. package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
  98. package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
  99. package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
  100. package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
  101. package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
  102. package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
  103. package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
  104. package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
  105. package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
  106. package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
  107. package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
  108. package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
  109. package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
  110. package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
  111. package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
  112. package/web-app/dist/index.html +1 -1
  113. package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
@@ -16,7 +16,7 @@ var __export = (target, all) => {
16
16
 
17
17
  // src/server/app.ts
18
18
  import { existsSync as existsSync2, statSync } from "fs";
19
- import { join as join2, resolve, sep } from "path";
19
+ import { join as join3, resolve, sep } from "path";
20
20
 
21
21
  // node_modules/hono/dist/request/constants.js
22
22
  var GET_MATCH_RESULT = Symbol();
@@ -5535,11 +5535,14 @@ var runs = sqliteTable("runs", {
5535
5535
  lastSeq: integer("last_seq").notNull(),
5536
5536
  lastEventAt: text("last_event_at"),
5537
5537
  tampered: integer("tampered").notNull(),
5538
- conflict: integer("conflict").notNull().default(0)
5538
+ conflict: integer("conflict").notNull().default(0),
5539
+ groupId: text("group_id"),
5540
+ unitId: text("unit_id")
5539
5541
  }, (t) => [
5540
5542
  primaryKey({ columns: [t.sourceId, t.runId] }),
5541
5543
  index("runs_started").on(t.startedAt),
5542
- index("runs_verdict").on(t.verdict)
5544
+ index("runs_verdict").on(t.verdict),
5545
+ index("runs_group").on(t.groupId)
5543
5546
  ]);
5544
5547
 
5545
5548
  // src/db/migrate.ts
@@ -5581,6 +5584,11 @@ function validateEnvelope(x) {
5581
5584
  return "stage must be a string or null";
5582
5585
  if (!isObj(x.data))
5583
5586
  return "data must be an object";
5587
+ for (const k of ["group_id", "unit_id"])
5588
+ if (k in x.data && (typeof x.data[k] !== "string" || x.data[k] === ""))
5589
+ return `${k} must be a non-empty string`;
5590
+ if ("deps" in x.data && (!Array.isArray(x.data.deps) || x.data.deps.some((d) => typeof d !== "string")))
5591
+ return "deps must be a string array";
5584
5592
  return null;
5585
5593
  }
5586
5594
  function fold(events2) {
@@ -5658,6 +5666,35 @@ var RULES = [
5658
5666
  ];
5659
5667
  var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
5660
5668
 
5669
+ // src/server/answer.ts
5670
+ import { chmodSync, mkdirSync, writeFileSync } from "fs";
5671
+ import { homedir } from "os";
5672
+ import { join as join2 } from "path";
5673
+ var MAX_ANSWER = 4000;
5674
+ var ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
5675
+ var defaultAnswerDir = (env = process.env) => env.LOKI_CONTROL_ANSWER_DIR || join2(env.HOME || homedir(), ".loki", "control", "answers");
5676
+ function blockedQuestion(evs, verdict) {
5677
+ if (verdict !== "SPEC_CONFLICT")
5678
+ return null;
5679
+ const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
5680
+ return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
5681
+ }
5682
+ function writeAnswer(dir, source, run, answer) {
5683
+ if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes(".."))
5684
+ return { status: 400, body: { error: "invalid run id" } };
5685
+ if (typeof answer !== "string" || answer.trim() === "")
5686
+ return { status: 400, body: { error: "answer must be a non-empty string" } };
5687
+ if (answer.length > MAX_ANSWER)
5688
+ return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
5689
+ const d = join2(dir, source);
5690
+ mkdirSync(d, { recursive: true, mode: 448 });
5691
+ const path = join2(d, `${run}.answer.txt`);
5692
+ writeFileSync(path, `${answer.trim()}
5693
+ `, { mode: 384 });
5694
+ chmodSync(path, 384);
5695
+ return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
5696
+ }
5697
+
5661
5698
  // src/server/runs.ts
5662
5699
  var str = (x) => typeof x === "string" ? x : null;
5663
5700
  function loadEvents(db, sourceId, runId) {
@@ -5680,6 +5717,8 @@ function rebuildRun(db, sourceId, runId) {
5680
5717
  taskSource: str(sd.task_source),
5681
5718
  provider: str(sd.provider),
5682
5719
  model: str(sd.model),
5720
+ groupId: str(sd.group_id),
5721
+ unitId: str(sd.unit_id),
5683
5722
  startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null,
5684
5723
  endedAt: f.run.completed?.ts ?? null,
5685
5724
  verdict: f.run.verdict,
@@ -5704,6 +5743,8 @@ var parseRun = (r) => ({
5704
5743
  origin_repo: r.originRepo,
5705
5744
  issue_ref: r.issueRef,
5706
5745
  task_source: r.taskSource,
5746
+ group_id: r.groupId,
5747
+ unit_id: r.unitId,
5707
5748
  provider: r.provider,
5708
5749
  model: r.model,
5709
5750
  started_at: r.startedAt,
@@ -5763,7 +5804,7 @@ var elapsedS = (r) => {
5763
5804
  function listRuns(db, q) {
5764
5805
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
5765
5806
  const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0);
5766
- const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
5807
+ const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.group_id ? eq(runs.groupId, q.group_id) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
5767
5808
  const total = db.select({ n: sql`count(*)` }).from(runs).where(where).get()?.n ?? 0;
5768
5809
  const rows = db.select().from(runs).where(where).orderBy(desc(runs.startedAt), asc(runs.runId)).limit(limit).offset(offset).all();
5769
5810
  return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
@@ -5792,6 +5833,7 @@ function runDetail(db, sourceId, runId) {
5792
5833
  const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
5793
5834
  return {
5794
5835
  ...withLive(db, r),
5836
+ blocked_question: blockedQuestion(evs, r.verdict),
5795
5837
  stages,
5796
5838
  stages_completed: f.completed,
5797
5839
  receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
@@ -5846,14 +5888,64 @@ function ingest(db, body) {
5846
5888
  return conflict > 0 ? { status: 409, body: { ...out, error: "conflicting content for an existing event id" } } : { status: 200, body: out };
5847
5889
  }
5848
5890
 
5891
+ // src/server/auth.ts
5892
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
5893
+ var LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
5894
+ function isLoopbackHost(host) {
5895
+ return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
5896
+ }
5897
+ function tokenMatches(header, token) {
5898
+ const m = /^Bearer (.+)$/i.exec(header ?? "");
5899
+ if (!m)
5900
+ return false;
5901
+ const a = createHash2("sha256").update(m[1] ?? "").digest();
5902
+ const b = createHash2("sha256").update(token).digest();
5903
+ return timingSafeEqual(a, b);
5904
+ }
5905
+ function bindRefusal(env) {
5906
+ const host = env.LOKI_CONTROL_HOST;
5907
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
5908
+ return null;
5909
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1")
5910
+ return null;
5911
+ return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
5912
+ }
5913
+ function insecureBindWarning(env) {
5914
+ const host = env.LOKI_CONTROL_HOST;
5915
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
5916
+ return null;
5917
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1")
5918
+ return null;
5919
+ return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
5920
+ }
5921
+ function hostGuard() {
5922
+ return async (c, next) => {
5923
+ if (!isLoopbackHost(c.req.header("host")))
5924
+ return c.json({ error: "host not allowed" }, 403);
5925
+ await next();
5926
+ };
5927
+ }
5928
+ function tokenGuard(token) {
5929
+ return async (c, next) => {
5930
+ if (!tokenMatches(c.req.header("authorization"), token))
5931
+ return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
5932
+ await next();
5933
+ };
5934
+ }
5935
+
5849
5936
  // src/server/app.ts
5850
5937
  var MAX_BODY = 1e6;
5851
- var defaultUiDir = () => [join2(import.meta.dir, "../../ui/dist"), join2(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join2(d, "index.html")));
5938
+ var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
5852
5939
  function createApp(opts) {
5853
5940
  const uiDir = opts.uiDir ?? defaultUiDir();
5854
5941
  const { db, sqlite } = openDb(opts.dbPath);
5855
5942
  let ready = true;
5943
+ const answerDir = opts.answerDir ?? defaultAnswerDir();
5856
5944
  const app = new Hono3;
5945
+ if (opts.loopbackOnly)
5946
+ app.use("*", hostGuard());
5947
+ if (opts.token)
5948
+ app.use("/v1/*", tokenGuard(opts.token));
5857
5949
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
5858
5950
  app.get("/ready", (c) => {
5859
5951
  try {
@@ -5878,13 +5970,34 @@ function createApp(opts) {
5878
5970
  });
5879
5971
  app.get("/v1/runs", (c) => {
5880
5972
  const q = c.req.query();
5881
- return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
5973
+ return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
5882
5974
  });
5883
5975
  const detail = (c, source, run) => {
5884
5976
  const d = runDetail(db, source, run);
5885
5977
  return d ? c.json(d) : c.json({ error: "run not found" }, 404);
5886
5978
  };
5887
5979
  app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
5980
+ app.post("/v1/runs/:source/:run/answer", async (c) => {
5981
+ const source = c.req.param("source"), run = c.req.param("run");
5982
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json"))
5983
+ return c.json({ error: "content-type must be application/json" }, 400);
5984
+ const text2 = await c.req.text();
5985
+ if (text2.length > 20000)
5986
+ return c.json({ error: "body too large" }, 413);
5987
+ let body;
5988
+ try {
5989
+ body = JSON.parse(text2);
5990
+ } catch {
5991
+ return c.json({ error: "invalid JSON" }, 400);
5992
+ }
5993
+ const d = runDetail(db, source, run);
5994
+ if (!d)
5995
+ return c.json({ error: "run not found" }, 404);
5996
+ if (!d.blocked_question)
5997
+ return c.json({ error: "run is not blocked on a question" }, 409);
5998
+ const r = writeAnswer(answerDir, source, run, body?.answer);
5999
+ return c.json(r.body, r.status);
6000
+ });
5888
6001
  app.get("/v1/runs/:id", (c) => {
5889
6002
  const id = c.req.param("id");
5890
6003
  const i = id.indexOf(":");
@@ -5900,14 +6013,23 @@ function createApp(opts) {
5900
6013
  return new Response(Bun.file(f));
5901
6014
  if (/\.[a-z0-9]+$/i.test(p))
5902
6015
  return c.json({ error: "not found" }, 404);
5903
- return new Response(Bun.file(join2(root, "index.html")));
6016
+ return new Response(Bun.file(join3(root, "index.html")));
5904
6017
  });
5905
6018
  return { app, db, close: () => sqlite.close() };
5906
6019
  }
5907
6020
 
5908
6021
  // src/server/serve.ts
5909
- var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
5910
- var serve = (port) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
6022
+ var refusal = bindRefusal(process.env);
6023
+ if (refusal) {
6024
+ console.error(refusal);
6025
+ process.exit(2);
6026
+ }
6027
+ var warning = insecureBindWarning(process.env);
6028
+ if (warning)
6029
+ console.error(warning);
6030
+ var bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
6031
+ var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
6032
+ var serve = (port) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
5911
6033
  var s;
5912
6034
  try {
5913
6035
  s = serve(Number(process.env.PORT ?? 0));
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `runs` ADD `group_id` text;--> statement-breakpoint
2
+ ALTER TABLE `runs` ADD `unit_id` text;--> statement-breakpoint
3
+ CREATE INDEX `runs_group` ON `runs` (`group_id`);
@@ -8,6 +8,13 @@
8
8
  "when": 1790870373167,
9
9
  "tag": "0000_new_maelstrom",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1790956800000,
16
+ "tag": "0001_group_id",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
- }
20
+ }
@@ -47,8 +47,11 @@ export const runs = sqliteTable("runs", {
47
47
  lastEventAt: text("last_event_at"),
48
48
  tampered: integer("tampered").notNull(),
49
49
  conflict: integer("conflict").notNull().default(0),
50
+ groupId: text("group_id"),
51
+ unitId: text("unit_id"),
50
52
  }, (t) => [
51
53
  primaryKey({ columns: [t.sourceId, t.runId] }),
52
54
  index("runs_started").on(t.startedAt),
53
55
  index("runs_verdict").on(t.verdict),
56
+ index("runs_group").on(t.groupId),
54
57
  ]);
@@ -0,0 +1,33 @@
1
+ // BLOCKED answer: a human reply to a run's one question, written to a file the operator resumes from.
2
+ // File: <answerDir>/<source>/<run>.answer.txt (default ~/.loki/control/answers, override LOKI_CONTROL_ANSWER_DIR).
3
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
7
+
8
+ export const MAX_ANSWER = 4000;
9
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+
11
+ export const defaultAnswerDir = (env: NodeJS.ProcessEnv = process.env): string =>
12
+ env.LOKI_CONTROL_ANSWER_DIR || join(env.HOME || homedir(), ".loki", "control", "answers");
13
+
14
+ /** The question a BLOCKED (spec conflict) run is waiting on, or null when it is not blocked. */
15
+ export function blockedQuestion(evs: EventEnvelope[], verdict: string | null): string | null {
16
+ if (verdict !== "SPEC_CONFLICT") return null;
17
+ const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
18
+ return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
19
+ }
20
+
21
+ export type AnswerResult = { status: 200; body: { ok: true; path: string; resume: string } } | { status: 400 | 413; body: { error: string } };
22
+
23
+ export function writeAnswer(dir: string, source: string, run: string, answer: unknown): AnswerResult {
24
+ if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes("..")) return { status: 400, body: { error: "invalid run id" } };
25
+ if (typeof answer !== "string" || answer.trim() === "") return { status: 400, body: { error: "answer must be a non-empty string" } };
26
+ if (answer.length > MAX_ANSWER) return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
27
+ const d = join(dir, source);
28
+ mkdirSync(d, { recursive: true, mode: 0o700 });
29
+ const path = join(d, `${run}.answer.txt`);
30
+ writeFileSync(path, `${answer.trim()}\n`, { mode: 0o600 });
31
+ chmodSync(path, 0o600);
32
+ return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
33
+ }
@@ -3,7 +3,9 @@ import { join, resolve, sep } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { openDb } from "../db/migrate.ts";
5
5
  import { ingest } from "./ingest.ts";
6
+ import { defaultAnswerDir, writeAnswer } from "./answer.ts";
6
7
  import { listRuns, runDetail } from "./runs.ts";
8
+ import { hostGuard, tokenGuard } from "./auth.ts";
7
9
 
8
10
  const MAX_BODY = 1_000_000;
9
11
 
@@ -11,11 +13,15 @@ const MAX_BODY = 1_000_000;
11
13
  const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
12
14
 
13
15
  /** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
14
- export function createApp(opts: { dbPath: string; uiDir?: string }) {
16
+ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean }) {
15
17
  const uiDir = opts.uiDir ?? defaultUiDir();
16
18
  const { db, sqlite } = openDb(opts.dbPath);
17
19
  let ready = true;
20
+ const answerDir = opts.answerDir ?? defaultAnswerDir();
18
21
  const app = new Hono();
22
+ // token: bearer required on /v1/*. loopbackOnly: reject a non-loopback Host (DNS rebinding). Both off by default; serve.ts sets them from env.
23
+ if (opts.loopbackOnly) app.use("*", hostGuard());
24
+ if (opts.token) app.use("/v1/*", tokenGuard(opts.token));
19
25
 
20
26
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
21
27
  app.get("/ready", (c) => {
@@ -34,13 +40,27 @@ export function createApp(opts: { dbPath: string; uiDir?: string }) {
34
40
 
35
41
  app.get("/v1/runs", (c) => {
36
42
  const q = c.req.query();
37
- return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
43
+ return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
38
44
  });
39
45
  const detail = (c: { json: (b: unknown, s?: 404) => Response }, source: string, run: string) => {
40
46
  const d = runDetail(db, source, run);
41
47
  return d ? c.json(d) : c.json({ error: "run not found" }, 404);
42
48
  };
43
49
  app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
50
+ // BLOCKED answer: only for a run blocked on a question; JSON content type required (blocks cross-site form posts).
51
+ app.post("/v1/runs/:source/:run/answer", async (c) => {
52
+ const source = c.req.param("source"), run = c.req.param("run");
53
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 400);
54
+ const text = await c.req.text();
55
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
56
+ let body: { answer?: unknown } | null;
57
+ try { body = JSON.parse(text) as { answer?: unknown } | null; } catch { return c.json({ error: "invalid JSON" }, 400); }
58
+ const d = runDetail(db, source, run);
59
+ if (!d) return c.json({ error: "run not found" }, 404);
60
+ if (!d.blocked_question) return c.json({ error: "run is not blocked on a question" }, 409);
61
+ const r = writeAnswer(answerDir, source, run, body?.answer);
62
+ return c.json(r.body, r.status);
63
+ });
44
64
  // :id is `source:run` (run ids never contain a colon)
45
65
  app.get("/v1/runs/:id", (c) => {
46
66
  const id = c.req.param("id");
@@ -0,0 +1,51 @@
1
+ // Control Plane auth: bearer token on /v1, Host allowlist on loopback (DNS rebinding), bind refusal without a token.
2
+ import { createHash, timingSafeEqual } from "node:crypto";
3
+ import type { MiddlewareHandler } from "hono";
4
+
5
+ const LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
6
+
7
+ /** True when a Host header value (optional port) names this machine's loopback. Strict: nothing may follow the name or port. */
8
+ export function isLoopbackHost(host: string | undefined | null): boolean {
9
+ return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
10
+ }
11
+
12
+ /** Constant-time check of an Authorization header against the token. Hashing first makes the compare length-safe. */
13
+ export function tokenMatches(header: string | undefined | null, token: string): boolean {
14
+ const m = /^Bearer (.+)$/i.exec(header ?? "");
15
+ if (!m) return false;
16
+ const a = createHash("sha256").update(m[1] ?? "").digest();
17
+ const b = createHash("sha256").update(token).digest();
18
+ return timingSafeEqual(a, b);
19
+ }
20
+
21
+ /** Message to print before exiting 2 when the env asks for a non-loopback bind with no token, else null. */
22
+ export function bindRefusal(env: Record<string, string | undefined>): string | null {
23
+ const host = env.LOKI_CONTROL_HOST;
24
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
25
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1") return null;
26
+ return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
27
+ }
28
+
29
+ /** Stderr warning when ALLOW_INSECURE_BIND is what permits a non-loopback bind with no token, else null. Never includes a token. */
30
+ export function insecureBindWarning(env: Record<string, string | undefined>): string | null {
31
+ const host = env.LOKI_CONTROL_HOST;
32
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
33
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1") return null;
34
+ return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
35
+ }
36
+
37
+ /** Host allowlist guard (apply on "*"; /health and /ready still need the Host check on loopback). */
38
+ export function hostGuard(): MiddlewareHandler {
39
+ return async (c, next) => {
40
+ if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "host not allowed" }, 403);
41
+ await next();
42
+ };
43
+ }
44
+
45
+ /** Bearer guard. Register it with app.use("/v1/*", ...) so Hono's decoded route matching decides coverage, never the raw URL. */
46
+ export function tokenGuard(token: string): MiddlewareHandler {
47
+ return async (c, next) => {
48
+ if (!tokenMatches(c.req.header("authorization"), token)) return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
49
+ await next();
50
+ };
51
+ }
@@ -3,6 +3,7 @@ import { fold, partialCost } from "../../../../loki-ts/src/engine10/events.ts";
3
3
  import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
4
4
  import type { Db } from "../db/migrate.ts";
5
5
  import { events, runs } from "../db/schema.ts";
6
+ import { blockedQuestion } from "./answer.ts";
6
7
 
7
8
  const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
8
9
 
@@ -23,7 +24,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
23
24
  const row = {
24
25
  sourceId, runId,
25
26
  originRepo: str(sd.origin_repo), issueRef: str(sd.issue_ref), taskSource: str(sd.task_source),
26
- provider: str(sd.provider), model: str(sd.model),
27
+ provider: str(sd.provider), model: str(sd.model), groupId: str(sd.group_id), unitId: str(sd.unit_id),
27
28
  startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null, endedAt: f.run.completed?.ts ?? null,
28
29
  verdict: f.run.verdict,
29
30
  prUrl: str(done.pr_url) ?? str(pr?.url), prDraft: typeof pr?.draft === "boolean" ? Number(pr.draft) : null,
@@ -38,7 +39,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
38
39
 
39
40
  const parseRun = (r: typeof runs.$inferSelect) => ({
40
41
  source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
41
- provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
42
+ group_id: r.groupId, unit_id: r.unitId, provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
42
43
  pr_url: r.prUrl, pr_draft: r.prDraft === null ? null : r.prDraft === 1,
43
44
  cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
44
45
  input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
@@ -74,7 +75,7 @@ const elapsedS = (r: typeof runs.$inferSelect): number | null => {
74
75
  return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
75
76
  };
76
77
 
77
- export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; limit?: number; cursor?: string }
78
+ export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; group_id?: string; limit?: number; cursor?: string }
78
79
 
79
80
  export function listRuns(db: Db, q: ListQuery) {
80
81
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
@@ -82,6 +83,7 @@ export function listRuns(db: Db, q: ListQuery) {
82
83
  const where = and(
83
84
  q.verdict ? eq(runs.verdict, q.verdict) : undefined,
84
85
  q.repo ? eq(runs.originRepo, q.repo) : undefined,
86
+ q.group_id ? eq(runs.groupId, q.group_id) : undefined,
85
87
  q.since ? gte(runs.startedAt, q.since) : undefined,
86
88
  q.until ? lt(runs.startedAt, q.until) : undefined,
87
89
  );
@@ -108,6 +110,7 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
108
110
  const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
109
111
  return {
110
112
  ...withLive(db, r),
113
+ blocked_question: blockedQuestion(evs, r.verdict),
111
114
  stages,
112
115
  stages_completed: f.completed,
113
116
  receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
@@ -1,10 +1,20 @@
1
1
  // Entry for `loki control serve` (bundled to dist/server.js). Config comes from env: PORT, LOKI_CONTROL_DB.
2
- // Binds loopback only. LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
2
+ // Binds loopback unless LOKI_CONTROL_HOST is set (the container image sets 0.0.0.0). LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
3
3
  // retries on any free port when PORT is taken; the URL printed below is always the real one.
4
4
  import { createApp } from "./app.ts";
5
+ import { bindRefusal, insecureBindWarning, isLoopbackHost } from "./auth.ts";
5
6
 
6
- const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
7
- const serve = (port: number) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
7
+ // A non-loopback bind needs LOKI_CONTROL_TOKEN (or LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Refuse before opening the DB or binding.
8
+ const refusal = bindRefusal(process.env);
9
+ if (refusal) {
10
+ console.error(refusal);
11
+ process.exit(2);
12
+ }
13
+ const warning = insecureBindWarning(process.env);
14
+ if (warning) console.error(warning);
15
+ const bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
16
+ const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
17
+ const serve = (port: number) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
8
18
  let s: ReturnType<typeof serve>;
9
19
  try {
10
20
  s = serve(Number(process.env.PORT ?? 0));
@@ -0,0 +1,29 @@
1
+ // C2 CP-DEFAULT: find a running local Control Plane (docs/v10/CONTROL-PLANE.md section 6). Reads
2
+ // ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
3
+ // within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
9
+
10
+ export interface DiscoverOpts { fetchImpl?: typeof fetch | undefined; alive?: ((pid: number) => boolean) | undefined }
11
+
12
+ const isAlive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch { return false; } };
13
+
14
+ export function isLoopbackHttp(u: string): boolean {
15
+ try { const p = new URL(u); return p.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(p.hostname); } catch { return false; }
16
+ }
17
+
18
+ export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts = {}): Promise<string | null> {
19
+ if (env.LOKI_CONTROL === "0") return null;
20
+ if (env.LOKI_CONTROL_URL) return env.LOKI_CONTROL_URL;
21
+ try {
22
+ const inst = JSON.parse(readFileSync(instancePath(env), "utf8")) as { pid?: unknown; url?: unknown };
23
+ if (!Number.isInteger(inst.pid) || typeof inst.url !== "string" || !(o.alive ?? isAlive)(inst.pid as number)) return null;
24
+ if (!isLoopbackHttp(inst.url)) return null; // a planted instance.json must never redirect events off this machine
25
+ const url = inst.url.replace(/\/+$/, "");
26
+ const h = (await (await (o.fetchImpl ?? fetch)(`${url}/health`, { signal: AbortSignal.timeout(300) })).json()) as { service?: string };
27
+ return h.service === "loki-control" ? url : null;
28
+ } catch { return null; }
29
+ }
@@ -1 +1 @@
1
- *,: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:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";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:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;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}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-48{width:12rem}.w-full{width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items: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-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.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-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-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.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-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.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-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-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.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-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.hover\:underline:hover{text-decoration-line:underline}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
1
+ *,: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:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";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:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;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}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-48{width:12rem}.w-full{width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items: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-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.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-sky-600{--tw-bg-opacity: 1;background-color:rgb(2 132 199 / 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-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.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-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-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-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / 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))}.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-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}