loki-mode 10.6.13 → 10.7.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 (110) hide show
  1. package/README.md +8 -5
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/acp-agent.py +128 -0
  5. package/autonomy/completion-council.sh +3 -3
  6. package/autonomy/docker-run.sh +8 -0
  7. package/autonomy/issue-parser.sh +8 -7
  8. package/autonomy/issue-providers.sh +159 -1
  9. package/autonomy/lib/engine10-push.sh +4 -1
  10. package/autonomy/lib/review_risk.py +91 -0
  11. package/autonomy/loki +225 -31
  12. package/autonomy/mobile-verify.sh +66 -0
  13. package/autonomy/quickstart.sh +44 -4
  14. package/autonomy/run.sh +44 -2
  15. package/completions/_loki +2 -0
  16. package/completions/loki.bash +1 -1
  17. package/dashboard/__init__.py +1 -1
  18. package/dashboard/api_runs_v1.py +80 -0
  19. package/dashboard/server.py +2 -0
  20. package/docs/COMPETITOR-DEPLOYMENT-MODELS.md +3 -3
  21. package/docs/INSTALLATION.md +5 -1
  22. package/docs/acp-editors.md +29 -0
  23. package/docs/api-runs-v1.md +12 -0
  24. package/docs/architecture/ADR-001-runtime-migration.md +13 -13
  25. package/docs/dev/architecture-reference.md +6 -2
  26. package/docs/merge-queue.md +13 -0
  27. package/docs/mobile-verify.md +11 -0
  28. package/docs/sentry-intake.md +13 -0
  29. package/docs/v10/BOARD.md +66 -12
  30. package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
  31. package/docs/v10/D61-SPEED.md +4 -0
  32. package/docs/v10/DECISIONS.md +85 -0
  33. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  34. package/docs/v10/GAP-RESEARCH-1003.md +32 -0
  35. package/docs/v10/MORNING-BRIEF-1003.md +39 -0
  36. package/docs/v10/PROGRESS.md +107 -0
  37. package/docs/v10/SWARM.md +32 -0
  38. package/docs/visual-evidence.md +5 -0
  39. package/loki-ts/dist/loki.js +766 -624
  40. package/mcp/__init__.py +1 -1
  41. package/package.json +1 -1
  42. package/packages/control-plane/dist/server.js +263 -9
  43. package/packages/control-plane/src/server/actions.ts +60 -0
  44. package/packages/control-plane/src/server/app.ts +39 -3
  45. package/packages/control-plane/src/shipper/watch.ts +64 -0
  46. package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +1 -0
  47. package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +129 -0
  48. package/packages/control-plane/ui/dist/index.html +3 -3
  49. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  50. package/skills/healing.md +2 -2
  51. package/tools/proof-passport.py +1 -1
  52. package/web-app/dist/assets/{AdminPage-CVEdde3t.js → AdminPage-BQPb5rc5.js} +1 -1
  53. package/web-app/dist/assets/{Avatar-D1r8n_Da.js → Avatar-Cttco6FC.js} +1 -1
  54. package/web-app/dist/assets/{Badge-BAPyJ97M.js → Badge-CPb6kpbH.js} +1 -1
  55. package/web-app/dist/assets/{Button-C-GCctTq.js → Button-caTL28a4.js} +1 -1
  56. package/web-app/dist/assets/{CockpitPage-CV9MhyuV.js → CockpitPage-bEbLx6ph.js} +1 -1
  57. package/web-app/dist/assets/{ComparePage-D1R5Lxmy.js → ComparePage-DNtJSij-.js} +1 -1
  58. package/web-app/dist/assets/{ErrorBoundary-CFvJx4vV.js → ErrorBoundary-BrgC3K44.js} +1 -1
  59. package/web-app/dist/assets/{EvidenceReceiptPanel-9I7dfPz0.js → EvidenceReceiptPanel-Q-mSrUUQ.js} +1 -1
  60. package/web-app/dist/assets/{GitHubIssuesPanel-8YH6WkTh.js → GitHubIssuesPanel-DbmYl_Er.js} +1 -1
  61. package/web-app/dist/assets/{GitHubPRsPanel-BZvEHBTF.js → GitHubPRsPanel-C6PCHM4B.js} +1 -1
  62. package/web-app/dist/assets/{HomePage-DhjWTo8R.js → HomePage-7zsL_k_e.js} +2 -2
  63. package/web-app/dist/assets/{LoginPage-BwNGyWxP.js → LoginPage-Cl4ON8RQ.js} +1 -1
  64. package/web-app/dist/assets/{MagicPage-Dg9z7deq.js → MagicPage-BNEAhTqH.js} +1 -1
  65. package/web-app/dist/assets/{MetricsPage-DLF1F6XC.js → MetricsPage-DyBH-88q.js} +1 -1
  66. package/web-app/dist/assets/{NotFoundPage-CZClMI3s.js → NotFoundPage-CIVr21yL.js} +1 -1
  67. package/web-app/dist/assets/{ProjectPage-CnAvyHDJ.js → ProjectPage-BMeVYocF.js} +3 -3
  68. package/web-app/dist/assets/{ProjectsPage-D33VGyVL.js → ProjectsPage-D-H2WRVU.js} +1 -1
  69. package/web-app/dist/assets/{SettingsPage-DQtlpXPe.js → SettingsPage-o0kR9RAt.js} +1 -1
  70. package/web-app/dist/assets/{ShowcasePage-CAitzJlY.js → ShowcasePage-DtfMDMjp.js} +1 -1
  71. package/web-app/dist/assets/{SystemSettingsPage-CV49UjwU.js → SystemSettingsPage-CkXdpZ_T.js} +1 -1
  72. package/web-app/dist/assets/{TeamsPage-5EKrbkN3.js → TeamsPage-Cu5H9cQ0.js} +1 -1
  73. package/web-app/dist/assets/{TemplatesPage-BRsrm00y.js → TemplatesPage-3wdbVwil.js} +1 -1
  74. package/web-app/dist/assets/{TerminalOutput-DgEnMwnQ.js → TerminalOutput-CWn301xh.js} +1 -1
  75. package/web-app/dist/assets/{activity-hXTYu4Zb.js → activity-YGaAt4zJ.js} +1 -1
  76. package/web-app/dist/assets/{bell-D5QkmMjg.js → bell-C28q0GO7.js} +1 -1
  77. package/web-app/dist/assets/{bot-CGOzPami.js → bot-DLcQGI92.js} +1 -1
  78. package/web-app/dist/assets/{check-HxPvzPXm.js → check-w0sBNlcm.js} +1 -1
  79. package/web-app/dist/assets/{chevron-left-D7HYUp4T.js → chevron-left-W7O0vC_m.js} +1 -1
  80. package/web-app/dist/assets/{circle-alert-BdufMh8P.js → circle-alert-Cxjq9fxB.js} +1 -1
  81. package/web-app/dist/assets/{clock-DMcS0xjR.js → clock-C8EqzmNl.js} +1 -1
  82. package/web-app/dist/assets/{cloud-Bt-0bh9m.js → cloud-xNgp4g3Y.js} +1 -1
  83. package/web-app/dist/assets/{code-xml-DRtDKt4_.js → code-xml-B-lyNDLJ.js} +1 -1
  84. package/web-app/dist/assets/{database-B0ChKZQ4.js → database-DoW5lmWc.js} +1 -1
  85. package/web-app/dist/assets/{dollar-sign-st_8sF_M.js → dollar-sign-BcARA5sC.js} +1 -1
  86. package/web-app/dist/assets/{file-code-corner-UdiLz5N6.js → file-code-corner-CehcII0J.js} +1 -1
  87. package/web-app/dist/assets/{file-plus-De1dimur.js → file-plus-DI9Zo5hP.js} +1 -1
  88. package/web-app/dist/assets/{globe-rrbXmytb.js → globe-CiZWBk3H.js} +1 -1
  89. package/web-app/dist/assets/{hammer-CBtNZi-S.js → hammer-3qv_T2N-.js} +1 -1
  90. package/web-app/dist/assets/{index-D3YvZaMV.js → index-gF9grAz-.js} +3 -3
  91. package/web-app/dist/assets/{layers-BmthCdkO.js → layers-CN7d0By9.js} +1 -1
  92. package/web-app/dist/assets/{loader-circle-C_sXFJUa.js → loader-circle-DywcM-im.js} +1 -1
  93. package/web-app/dist/assets/{lock-DT6V3D97.js → lock-_hdSmkU4.js} +1 -1
  94. package/web-app/dist/assets/{package-D86K1oU6.js → package-BV5zgqxJ.js} +1 -1
  95. package/web-app/dist/assets/{plus-Dwkssum8.js → plus-CU_Hh4-x.js} +1 -1
  96. package/web-app/dist/assets/{refresh-cw-DhRlqf1F.js → refresh-cw-DMK-fNl-.js} +1 -1
  97. package/web-app/dist/assets/{rotate-ccw-EvA5ZJVL.js → rotate-ccw-CEB3_v5_.js} +1 -1
  98. package/web-app/dist/assets/{scroll-text-D4ABo43E.js → scroll-text-BSdXxcRN.js} +1 -1
  99. package/web-app/dist/assets/{server-BAAdr_fD.js → server-CAMlP1yJ.js} +1 -1
  100. package/web-app/dist/assets/{shield-alert-DJPLy9t8.js → shield-alert-B-r1Dt3h.js} +1 -1
  101. package/web-app/dist/assets/{thumbs-up-D07JqmoS.js → thumbs-up-CYfxw9o4.js} +1 -1
  102. package/web-app/dist/assets/{trash-2-BJYwlHzl.js → trash-2-DX3nGPS8.js} +1 -1
  103. package/web-app/dist/assets/{trending-down-Bo8txoOw.js → trending-down-Bt-hSntd.js} +1 -1
  104. package/web-app/dist/assets/{trending-up-Bb13tkln.js → trending-up-DVSlvQp8.js} +1 -1
  105. package/web-app/dist/assets/{upload-D60DiVNZ.js → upload-D45RRHAP.js} +1 -1
  106. package/web-app/dist/assets/{usePolling-DJmgDiio.js → usePolling-fq_Xj8iw.js} +1 -1
  107. package/web-app/dist/assets/{user-BmXCbSJm.js → user-BWcJkLlr.js} +1 -1
  108. package/web-app/dist/index.html +1 -1
  109. package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
  110. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +0 -114
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '10.6.13'
78
+ __version__ = '10.7.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "10.6.13",
4
+ "version": "10.7.0",
5
5
  "description": "An autonomous software factory that knows what it is supposed to deliver, and proves it did.",
6
6
  "keywords": [
7
7
  "agent",
@@ -15,8 +15,8 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // src/server/app.ts
18
- import { existsSync as existsSync2, statSync } from "fs";
19
- import { join as join3, resolve, sep } from "path";
18
+ import { existsSync as existsSync5, statSync as statSync2 } from "fs";
19
+ import { join as join6, resolve as resolve2, sep } from "path";
20
20
 
21
21
  // node_modules/hono/dist/request/constants.js
22
22
  var GET_MATCH_RESULT = Symbol();
@@ -5561,6 +5561,7 @@ function openDb(path) {
5561
5561
  import { createHash } from "crypto";
5562
5562
 
5563
5563
  // ../../loki-ts/src/engine10/events.ts
5564
+ import { closeSync, existsSync as existsSync2, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeSync } from "fs";
5564
5565
  var isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
5565
5566
  var ISO_TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
5566
5567
  var KEYS = ["v", "seq", "ts", "run", "type", "stage", "data"];
@@ -5591,6 +5592,28 @@ function validateEnvelope(x) {
5591
5592
  return "deps must be a string array";
5592
5593
  return null;
5593
5594
  }
5595
+ function parseLine(line) {
5596
+ if (line.trim() === "")
5597
+ return null;
5598
+ try {
5599
+ const x = JSON.parse(line);
5600
+ return validateEnvelope(x) === null ? x : null;
5601
+ } catch {
5602
+ return null;
5603
+ }
5604
+ }
5605
+ function readEvents(path) {
5606
+ if (!existsSync2(path))
5607
+ return [];
5608
+ const out = [];
5609
+ for (const line of readFileSync(path, "utf8").split(`
5610
+ `)) {
5611
+ const e = parseLine(line);
5612
+ if (e)
5613
+ out.push(e);
5614
+ }
5615
+ return out;
5616
+ }
5594
5617
  function fold(events2) {
5595
5618
  const f = {
5596
5619
  stages: {},
@@ -5667,7 +5690,7 @@ var RULES = [
5667
5690
  var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
5668
5691
 
5669
5692
  // src/server/answer.ts
5670
- import { chmodSync, mkdirSync, writeFileSync } from "fs";
5693
+ import { chmodSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
5671
5694
  import { homedir } from "os";
5672
5695
  import { join as join2 } from "path";
5673
5696
  var MAX_ANSWER = 4000;
@@ -5687,7 +5710,7 @@ function writeAnswer(dir, source, run, answer) {
5687
5710
  if (answer.length > MAX_ANSWER)
5688
5711
  return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
5689
5712
  const d = join2(dir, source);
5690
- mkdirSync(d, { recursive: true, mode: 448 });
5713
+ mkdirSync2(d, { recursive: true, mode: 448 });
5691
5714
  const path = join2(d, `${run}.answer.txt`);
5692
5715
  writeFileSync(path, `${answer.trim()}
5693
5716
  `, { mode: 384 });
@@ -5933,9 +5956,196 @@ function tokenGuard(token) {
5933
5956
  };
5934
5957
  }
5935
5958
 
5959
+ // src/shipper/backfill.ts
5960
+ import { readdirSync } from "fs";
5961
+ import { join as join4 } from "path";
5962
+
5963
+ // src/shipper/ship.ts
5964
+ import { createHash as createHash3 } from "crypto";
5965
+ import { existsSync as existsSync3, readFileSync as readFileSync2, realpathSync, renameSync, writeFileSync as writeFileSync2 } from "fs";
5966
+ import { hostname } from "os";
5967
+ import { join as join3 } from "path";
5968
+ var BATCH = 200;
5969
+ var MAX_BYTES = 800000;
5970
+ function sourceId(repoDir) {
5971
+ let real2 = repoDir;
5972
+ try {
5973
+ real2 = realpathSync(repoDir);
5974
+ } catch {}
5975
+ return createHash3("sha256").update(`${hostname()}\x00${real2}`).digest("hex").slice(0, 16);
5976
+ }
5977
+ var redactDeep = (x) => typeof x === "string" ? redactSecrets(x) : Array.isArray(x) ? x.map(redactDeep) : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, redactDeep(v)])) : x;
5978
+ var redactEvent = (e) => ({ ...e, data: redactDeep(e.data) });
5979
+ var backoffMs = (failures, rnd = Math.random) => Math.min(60000, 1000 * 2 ** Math.max(0, failures - 1)) * (1 + 0.25 * rnd());
5980
+ function readShip(runDir, url) {
5981
+ try {
5982
+ const s = JSON.parse(readFileSync2(join3(runDir, "ship.json"), "utf8"));
5983
+ if (s.url === url && Number.isInteger(s.acked_seq))
5984
+ return { acked_seq: s.acked_seq, url };
5985
+ } catch {}
5986
+ return { acked_seq: -1, url };
5987
+ }
5988
+ function writeShip(runDir, s) {
5989
+ const tmp = join3(runDir, `ship.json.${process.pid}.tmp`);
5990
+ writeFileSync2(tmp, JSON.stringify(s) + `
5991
+ `);
5992
+ renameSync(tmp, join3(runDir, "ship.json"));
5993
+ }
5994
+ async function shipRun(o) {
5995
+ const st = readShip(o.runDir, o.url);
5996
+ const all = readEvents(join3(o.runDir, "events.jsonl"));
5997
+ let lines = 0;
5998
+ try {
5999
+ lines = readFileSync2(join3(o.runDir, "events.jsonl"), "utf8").split(`
6000
+ `).filter((l) => l.trim() !== "").length;
6001
+ } catch {}
6002
+ const skipped = lines - all.length;
6003
+ const todo = all.filter((e) => e.seq > st.acked_seq).sort((a, b) => a.seq - b.seq);
6004
+ if (todo.length === 0)
6005
+ return { ok: true, sent: 0, acked_seq: st.acked_seq, pending: 0 };
6006
+ const run_id = todo[0].run;
6007
+ const f = o.fetchImpl ?? fetch;
6008
+ let sent = 0;
6009
+ const fail = () => ({ ok: false, sent, acked_seq: st.acked_seq, pending: todo.length - sent });
6010
+ for (let i = 0;i < todo.length; ) {
6011
+ const batch = [];
6012
+ for (let bytes = 0;i < todo.length && batch.length < BATCH; i++) {
6013
+ bytes += JSON.stringify(todo[i]).length;
6014
+ if (batch.length > 0 && bytes > MAX_BYTES)
6015
+ break;
6016
+ batch.push(redactEvent(todo[i]));
6017
+ }
6018
+ try {
6019
+ const r = await f(`${o.url.replace(/\/+$/, "")}/v1/ingest`, {
6020
+ method: "POST",
6021
+ headers: { "content-type": "application/json", ...o.token ? { authorization: `Bearer ${o.token}` } : {} },
6022
+ body: JSON.stringify({ source: o.source, run_id, events: batch }),
6023
+ signal: AbortSignal.timeout(o.timeoutMs ?? 1e4)
6024
+ });
6025
+ if (!r.ok && r.status !== 409)
6026
+ return fail();
6027
+ } catch {
6028
+ return fail();
6029
+ }
6030
+ st.acked_seq = batch[batch.length - 1].seq;
6031
+ st.skipped = skipped;
6032
+ sent += batch.length;
6033
+ try {
6034
+ writeShip(o.runDir, st);
6035
+ } catch {
6036
+ return fail();
6037
+ }
6038
+ }
6039
+ return { ok: true, sent, acked_seq: st.acked_seq, pending: 0 };
6040
+ }
6041
+ var shipEnabled = (env) => env.LOKI_CONTROL_URL || null;
6042
+ var hasSpool = (runDir) => existsSync3(join3(runDir, "events.jsonl"));
6043
+
6044
+ // src/shipper/backfill.ts
6045
+ async function backfill(o) {
6046
+ const env = o.env ?? process.env;
6047
+ const url = shipEnabled(env);
6048
+ if (!url)
6049
+ return { runs: 0, sent: 0, failed: [] };
6050
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6051
+ const attempts = o.attempts ?? 3;
6052
+ const root = join4(o.repoDir, ".loki", "runs");
6053
+ let ids = [];
6054
+ try {
6055
+ ids = readdirSync(root).sort();
6056
+ } catch {
6057
+ return { runs: 0, sent: 0, failed: [] };
6058
+ }
6059
+ const source = sourceId(o.repoDir);
6060
+ let runs2 = 0, sent = 0;
6061
+ const failed = [];
6062
+ for (const id of ids) {
6063
+ const runDir = join4(root, id);
6064
+ if (!hasSpool(runDir))
6065
+ continue;
6066
+ runs2++;
6067
+ let res = { ok: false, sent: 0, acked_seq: -1, pending: 0 };
6068
+ for (let a = 1;a <= attempts; a++) {
6069
+ res = await shipRun({ runDir, url, source, token: env.LOKI_CONTROL_TOKEN, fetchImpl: o.fetchImpl });
6070
+ sent += res.sent;
6071
+ if (res.ok)
6072
+ break;
6073
+ if (a < attempts)
6074
+ await sleep(backoffMs(a));
6075
+ }
6076
+ if (!res.ok)
6077
+ failed.push(id);
6078
+ }
6079
+ return { runs: runs2, sent, failed };
6080
+ }
6081
+
6082
+ // src/server/actions.ts
6083
+ import { spawn } from "child_process";
6084
+ import { existsSync as existsSync4, readFileSync as readFileSync3, statSync } from "fs";
6085
+ import { homedir as homedir2 } from "os";
6086
+ import { join as join5, resolve } from "path";
6087
+ var ISSUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,8}$/;
6088
+ var TASK = /^[A-Za-z0-9][A-Za-z0-9 .,_/#@+=-]{0,499}$/;
6089
+ var hasDotDot = (t) => t.split(/[\\/ ]/).some((seg) => seg.startsWith(".."));
6090
+ function childEnv(env = process.env) {
6091
+ const e = { ...env, LOKI_NO_BROWSER: "1" };
6092
+ for (const k of ["LOKI_CONTROL_TOKEN", "LOKI_CONTROL_DB", "LOKI_CONTROL_HOST", "PORT"])
6093
+ delete e[k];
6094
+ return e;
6095
+ }
6096
+ function registryRepos(env = process.env) {
6097
+ try {
6098
+ const j = JSON.parse(readFileSync3(join5(env.HOME || homedir2(), ".loki", "dashboard", "projects.json"), "utf8"));
6099
+ return Object.values(j.projects ?? {}).map((p) => typeof p?.path === "string" ? p.path : "").filter((p) => p && existsSync4(p) && statSync(p).isDirectory());
6100
+ } catch {
6101
+ return [];
6102
+ }
6103
+ }
6104
+ function planStart(body, known, bin = "loki") {
6105
+ const b = body && typeof body === "object" ? body : {};
6106
+ if (typeof b.target !== "string")
6107
+ return { ok: false, error: "target must be a string" };
6108
+ const target = b.target.trim();
6109
+ const issue = ISSUE.test(target);
6110
+ if (!issue && !TASK.test(target))
6111
+ return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
6112
+ if (hasDotDot(target))
6113
+ return { ok: false, error: "target must not contain a .. path segment" };
6114
+ let cwd = process.cwd();
6115
+ if (b.repo !== undefined && b.repo !== "") {
6116
+ if (typeof b.repo !== "string" || b.repo.includes("\x00"))
6117
+ return { ok: false, error: "repo must be a path string" };
6118
+ const want = resolve(b.repo);
6119
+ const hit = known.map((k) => resolve(k)).find((k) => k === want);
6120
+ if (!hit)
6121
+ return { ok: false, error: "repo is not a known project" };
6122
+ cwd = hit;
6123
+ }
6124
+ return { ok: true, argv: issue ? [bin, "start", target] : [bin, "start", "--brief", target], cwd };
6125
+ }
6126
+ function spawnStart(argv, cwd, onExit = () => {}) {
6127
+ return new Promise((done) => {
6128
+ try {
6129
+ const [cmd, ...args] = argv;
6130
+ const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", shell: false, env: childEnv() });
6131
+ child.once("exit", onExit);
6132
+ child.once("error", (e) => {
6133
+ onExit();
6134
+ done({ error: `could not start loki: ${e.message}` });
6135
+ });
6136
+ child.once("spawn", () => {
6137
+ child.unref();
6138
+ done({ pid: child.pid ?? 0 });
6139
+ });
6140
+ } catch (e) {
6141
+ done({ error: `could not start loki: ${e.message}` });
6142
+ }
6143
+ });
6144
+ }
6145
+
5936
6146
  // src/server/app.ts
5937
6147
  var MAX_BODY = 1e6;
5938
- var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
6148
+ var defaultUiDir = () => [join6(import.meta.dir, "../../ui/dist"), join6(import.meta.dir, "../ui/dist")].find((d) => existsSync5(join6(d, "index.html")));
5939
6149
  function createApp(opts) {
5940
6150
  const uiDir = opts.uiDir ?? defaultUiDir();
5941
6151
  const { db, sqlite } = openDb(opts.dbPath);
@@ -6006,6 +6216,50 @@ function createApp(opts) {
6006
6216
  const r = writeAnswer(answerDir, source, run, body?.answer);
6007
6217
  return c.json(r.body, r.status);
6008
6218
  });
6219
+ const act = opts.loopbackOnly ? app : new Hono3;
6220
+ const peerIsLoopback = (c) => {
6221
+ const ip = c.env?.requestIP?.(c.req.raw)?.address;
6222
+ return typeof ip === "string" && /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/.test(ip);
6223
+ };
6224
+ const local = (c) => peerIsLoopback(c) && isLoopbackHost(c.req.header("host")) && (c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json");
6225
+ const repoDir = opts.repoDir ?? process.cwd();
6226
+ const inflight = new Set;
6227
+ act.post("/v1/import", async (c) => {
6228
+ if (!local(c))
6229
+ return c.json({ error: "loopback JSON requests only" }, 403);
6230
+ const r = await backfill({
6231
+ repoDir,
6232
+ env: { LOKI_CONTROL_URL: "http://127.0.0.1", LOKI_CONTROL_TOKEN: opts.token },
6233
+ fetchImpl: (u, init) => app.fetch(new Request(u, { ...init, headers: { ...init.headers, host: "127.0.0.1" } }))
6234
+ });
6235
+ return c.json({ runs: r.runs, sent: r.sent, failed: r.failed });
6236
+ });
6237
+ act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: [...new Set([repoDir, ...registryRepos()])] }) : c.json({ error: "loopback only" }, 403));
6238
+ act.post("/v1/start", async (c) => {
6239
+ if (!local(c))
6240
+ return c.json({ error: "loopback JSON requests only" }, 403);
6241
+ const text2 = await c.req.text();
6242
+ if (text2.length > 20000)
6243
+ return c.json({ error: "body too large" }, 413);
6244
+ let body;
6245
+ try {
6246
+ body = JSON.parse(text2);
6247
+ } catch {
6248
+ return c.json({ error: "invalid JSON" }, 400);
6249
+ }
6250
+ const plan = planStart(body, [repoDir, ...registryRepos()], opts.startBin);
6251
+ if (!plan.ok)
6252
+ return c.json({ error: plan.error }, 400);
6253
+ if (inflight.has(plan.cwd))
6254
+ return c.json({ error: "a run is already starting or running in this repo" }, 409);
6255
+ inflight.add(plan.cwd);
6256
+ const r = await (opts.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd));
6257
+ if ("error" in r) {
6258
+ inflight.delete(plan.cwd);
6259
+ return c.json({ error: r.error }, 500);
6260
+ }
6261
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
6262
+ });
6009
6263
  app.get("/v1/runs/:id", (c) => {
6010
6264
  const id = c.req.param("id");
6011
6265
  const i = id.indexOf(":");
@@ -6020,13 +6274,13 @@ function createApp(opts) {
6020
6274
  }
6021
6275
  if (!uiDir || p.startsWith("/v1/"))
6022
6276
  return c.json({ error: "not found" }, 404);
6023
- const root = resolve(uiDir);
6024
- const f = resolve(root, `.${p}`);
6025
- if (f.startsWith(root + sep) && existsSync2(f) && statSync(f).isFile())
6277
+ const root = resolve2(uiDir);
6278
+ const f = resolve2(root, `.${p}`);
6279
+ if (f.startsWith(root + sep) && existsSync5(f) && statSync2(f).isFile())
6026
6280
  return new Response(Bun.file(f));
6027
6281
  if (/\.[a-z0-9]+$/i.test(p))
6028
6282
  return c.json({ error: "not found" }, 404);
6029
- return new Response(Bun.file(join3(root, "index.html")));
6283
+ return new Response(Bun.file(join6(root, "index.html")));
6030
6284
  });
6031
6285
  return { app, db, close: () => sqlite.close() };
6032
6286
  }
@@ -0,0 +1,60 @@
1
+ // Start-a-run and repo-picker helpers for the UI. 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 { 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
+ export type StartPlan = { ok: true; argv: string[]; cwd: string } | { ok: false; error: string };
20
+
21
+ /** Registered project paths from ~/.loki/dashboard/projects.json (the `loki projects` registry), existing directories only. */
22
+ export function registryRepos(env: NodeJS.ProcessEnv = process.env): string[] {
23
+ try {
24
+ const j = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
25
+ return Object.values(j.projects ?? {}).map((p) => (typeof p?.path === "string" ? p.path : "")).filter((p) => p && existsSync(p) && statSync(p).isDirectory());
26
+ } catch { return []; }
27
+ }
28
+
29
+ /** Validate a start request. The repo must be one the server already knows (registry or cwd), never an arbitrary path. */
30
+ export function planStart(body: unknown, known: string[], bin = "loki"): StartPlan {
31
+ const b = (body && typeof body === "object" ? body : {}) as { target?: unknown; repo?: unknown };
32
+ if (typeof b.target !== "string") return { ok: false, error: "target must be a string" };
33
+ const target = b.target.trim();
34
+ const issue = ISSUE.test(target);
35
+ if (!issue && !TASK.test(target)) return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
36
+ if (hasDotDot(target)) return { ok: false, error: "target must not contain a .. path segment" };
37
+ let cwd = process.cwd();
38
+ if (b.repo !== undefined && b.repo !== "") {
39
+ if (typeof b.repo !== "string" || b.repo.includes("\0")) return { ok: false, error: "repo must be a path string" };
40
+ const want = resolve(b.repo);
41
+ const hit = known.map((k) => resolve(k)).find((k) => k === want);
42
+ if (!hit) return { ok: false, error: "repo is not a known project" };
43
+ cwd = hit;
44
+ }
45
+ // Free text goes as an explicit brief, never auto-detected as a PRD path.
46
+ return { ok: true, argv: issue ? [bin, "start", target] : [bin, "start", "--brief", target], cwd };
47
+ }
48
+
49
+ /** Spawn detached with no shell; resolves with the pid once the process exists, or an error if it cannot be launched. */
50
+ export function spawnStart(argv: string[], cwd: string, onExit: () => void = () => {}): Promise<{ pid: number } | { error: string }> {
51
+ return new Promise((done) => {
52
+ try {
53
+ const [cmd, ...args] = argv;
54
+ const child = spawn(cmd!, args, { cwd, detached: true, stdio: "ignore", shell: false, env: childEnv() });
55
+ child.once("exit", onExit);
56
+ child.once("error", (e) => { onExit(); done({ error: `could not start loki: ${e.message}` }); });
57
+ child.once("spawn", () => { child.unref(); done({ pid: child.pid ?? 0 }); });
58
+ } catch (e) { done({ error: `could not start loki: ${(e as Error).message}` }); }
59
+ });
60
+ }
@@ -1,11 +1,13 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
- import { Hono } from "hono";
3
+ import { Hono, type Context } from "hono";
4
4
  import { openDb } from "../db/migrate.ts";
5
5
  import { ingest } from "./ingest.ts";
6
6
  import { defaultAnswerDir, writeAnswer } from "./answer.ts";
7
7
  import { listRuns, runDetail } from "./runs.ts";
8
- import { hostGuard, tokenGuard } from "./auth.ts";
8
+ import { hostGuard, isLoopbackHost, tokenGuard } from "./auth.ts";
9
+ import { backfill } from "../shipper/backfill.ts";
10
+ import { planStart, registryRepos, spawnStart } from "./actions.ts";
9
11
 
10
12
  const MAX_BODY = 1_000_000;
11
13
 
@@ -13,7 +15,7 @@ const MAX_BODY = 1_000_000;
13
15
  const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
14
16
 
15
17
  /** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
16
- export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean }) {
18
+ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean; repoDir?: string; startBin?: string; spawnImpl?: typeof spawnStart }) {
17
19
  const uiDir = opts.uiDir ?? defaultUiDir();
18
20
  const { db, sqlite } = openDb(opts.dbPath);
19
21
  let ready = true;
@@ -66,6 +68,40 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
66
68
  const r = writeAnswer(answerDir, source, run, body?.answer);
67
69
  return c.json(r.body, r.status);
68
70
  });
71
+ // Machine-touching actions exist ONLY on a loopback-bound server (otherwise they are never registered: 404).
72
+ // Each request must also come from a loopback peer (the real socket address, not the spoofable Host header; unknown peer fails closed) and carry JSON (blocks cross-site form posts).
73
+ const act = opts.loopbackOnly ? app : new Hono();
74
+ const peerIsLoopback = (c: Context): boolean => {
75
+ const ip = (c.env as { requestIP?: (r: Request) => { address?: string } | null } | undefined)?.requestIP?.(c.req.raw)?.address;
76
+ return typeof ip === "string" && /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/.test(ip);
77
+ };
78
+ const local = (c: Context) => peerIsLoopback(c) && isLoopbackHost(c.req.header("host")) && (c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json");
79
+ const repoDir = opts.repoDir ?? process.cwd();
80
+ const inflight = new Set<string>();
81
+ act.post("/v1/import", async (c) => {
82
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
83
+ const r = await backfill({
84
+ repoDir,
85
+ env: { LOKI_CONTROL_URL: "http://127.0.0.1", LOKI_CONTROL_TOKEN: opts.token },
86
+ fetchImpl: ((u: string, init: RequestInit) => app.fetch(new Request(u, { ...init, headers: { ...(init.headers as Record<string, string>), host: "127.0.0.1" } }))) as unknown as typeof fetch,
87
+ });
88
+ return c.json({ runs: r.runs, sent: r.sent, failed: r.failed });
89
+ });
90
+ act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: [...new Set<string>([repoDir, ...registryRepos()])] }) : c.json({ error: "loopback only" }, 403));
91
+ act.post("/v1/start", async (c) => {
92
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
93
+ const text = await c.req.text();
94
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
95
+ let body: unknown;
96
+ try { body = JSON.parse(text); } catch { return c.json({ error: "invalid JSON" }, 400); }
97
+ const plan = planStart(body, [repoDir, ...registryRepos()], opts.startBin);
98
+ if (!plan.ok) return c.json({ error: plan.error }, 400);
99
+ if (inflight.has(plan.cwd)) return c.json({ error: "a run is already starting or running in this repo" }, 409);
100
+ inflight.add(plan.cwd);
101
+ const r = await (opts.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd));
102
+ if ("error" in r) { inflight.delete(plan.cwd); return c.json({ error: r.error }, 500); }
103
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
104
+ });
69
105
  // :id is `source:run` (run ids never contain a colon)
70
106
  app.get("/v1/runs/:id", (c) => {
71
107
  const id = c.req.param("id");
@@ -0,0 +1,64 @@
1
+ // CP-INGEST: zero-homework ingest. On Control Plane start, backfill the current repo plus every repo in the project
2
+ // registry (~/.loki/dashboard/projects.json), then poll each repo's .loki/runs and ship appended events.jsonl lines
3
+ // through the same shipRun path as the live hook (idempotent: ship.json holds the acked seq, the server dedupes).
4
+ import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { backfill } from "./backfill.ts";
8
+ import { shipRun, sourceId } from "./ship.ts";
9
+
10
+ /** Repo dirs from the dashboard project registry; never throws, a missing or corrupt registry means none. */
11
+ export function registryRepos(env: NodeJS.ProcessEnv): string[] {
12
+ try {
13
+ const reg = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
14
+ return Object.values(reg.projects ?? {}).map((p) => p.path).filter((p): p is string => typeof p === "string" && p.startsWith("/"));
15
+ } catch { return []; }
16
+ }
17
+
18
+ export interface WatchOpts {
19
+ repoDir: string;
20
+ url: string;
21
+ env?: NodeJS.ProcessEnv;
22
+ intervalMs?: number;
23
+ fetchImpl?: typeof fetch;
24
+ }
25
+ export interface Watcher { stop: () => void; tick: () => Promise<number> }
26
+
27
+ const sig = (f: string): string => { try { const s = statSync(f); return `${s.size}:${s.mtimeMs}`; } catch { return ""; } };
28
+
29
+ /** Backfill every repo, then watch. Resolves once the backfill is done; the watcher keeps polling until stop(). */
30
+ export async function ingestAndWatch(o: WatchOpts): Promise<Watcher> {
31
+ const env: NodeJS.ProcessEnv = { ...(o.env ?? process.env), LOKI_CONTROL_URL: o.url };
32
+ const repos = [...new Set([o.repoDir, ...registryRepos(env)])];
33
+ const seen = new Map<string, string>(); // events.jsonl path -> size:mtime last shipped OK
34
+ for (const repoDir of repos) {
35
+ try { await backfill({ repoDir, env, attempts: 1, fetchImpl: o.fetchImpl }); } catch { /* one bad repo must not block the rest */ }
36
+ }
37
+ let busy = false;
38
+ const tick = async (): Promise<number> => {
39
+ if (busy) return 0;
40
+ busy = true;
41
+ let sent = 0;
42
+ try {
43
+ for (const repoDir of repos) {
44
+ const root = join(repoDir, ".loki", "runs");
45
+ let ids: string[] = [];
46
+ try { ids = readdirSync(root); } catch { continue; }
47
+ const source = sourceId(repoDir);
48
+ for (const id of ids) {
49
+ const runDir = join(root, id), f = join(runDir, "events.jsonl"), s = sig(f);
50
+ if (s === "" || seen.get(f) === s) continue;
51
+ try {
52
+ const r = await shipRun({ runDir, url: o.url, source, token: env.LOKI_CONTROL_TOKEN, fetchImpl: o.fetchImpl });
53
+ sent += r.sent;
54
+ if (r.ok) seen.set(f, s);
55
+ } catch { /* retry next tick */ }
56
+ }
57
+ }
58
+ } finally { busy = false; }
59
+ return sent;
60
+ };
61
+ const timer = setInterval(() => { void tick(); }, o.intervalMs ?? 1000);
62
+ (timer as { unref?: () => void }).unref?.();
63
+ return { stop: () => clearInterval(timer), tick };
64
+ }
@@ -0,0 +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}.mb-1{margin-bottom:.25rem}.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}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.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}.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%}.shrink-0{flex-shrink:0}.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-b{border-bottom-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-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.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: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-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-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)}.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)}.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-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / 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: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\:mb-6{margin-bottom:1.5rem}.md\:ml-0{margin-left:0}.md\:mt-6{margin-top:1.5rem}.md\:block{display:block}.md\:table-cell{display:table-cell}.md\:min-h-0{min-height:0px}.md\:w-48{width:12rem}.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\:flex-row{flex-direction:row}.md\:border-b-0{border-bottom-width:0px}.md\:border-r{border-right-width:1px}.md\:p-4{padding:1rem}.md\:p-6{padding:1.5rem}.md\:px-2{padding-left:.5rem;padding-right:.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}}