loki-mode 10.7.1 → 10.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +20 -32
  5. package/autonomy/run.sh +9 -5
  6. package/autonomy/tui.sh +5 -1
  7. package/dashboard/__init__.py +1 -1
  8. package/dashboard/api_phases.py +1 -1
  9. package/dashboard/api_start.py +0 -1
  10. package/dashboard/server.py +28 -146
  11. package/docs/COCKPIT-SPEC.md +3 -1
  12. package/docs/DASHBOARD-9.12-EVIDENCE.md +1 -1
  13. package/docs/DASHBOARD-ARCHITECTURE.md +13 -11
  14. package/docs/DASHBOARD_V2_CHECKLIST.md +2 -0
  15. package/docs/INSTALLATION.md +12 -11
  16. package/docs/MERGE-DEDUP-MAP.md +1 -1
  17. package/docs/MERGE-ROUTE-MAP.md +1 -1
  18. package/docs/MERGE3-PLAN.md +2 -2
  19. package/docs/R3-COST-OBSERVABILITY-DESIGN.md +2 -2
  20. package/docs/R4-TRUST-TRAJECTORY-DESIGN.md +1 -1
  21. package/docs/R5-AUTO-WIKI-DESIGN.md +1 -1
  22. package/docs/R6-ROLLBACK-CHECKPOINT-PLAN.md +1 -1
  23. package/docs/RARV-C-100X-PLAN.md +3 -3
  24. package/docs/SONNET5-DEFAULT-PLAN.md +2 -2
  25. package/docs/SYNERGY-TASKS.md +1 -1
  26. package/docs/TOOL-INTEGRATION.md +1 -1
  27. package/docs/TOP-100-BACKLOG.md +1 -1
  28. package/docs/alternative-installations.md +1 -1
  29. package/docs/architecture/ADR-001-runtime-migration.md +1 -1
  30. package/docs/architecture/DASHBOARD_V2_ARCHITECTURE.md +2 -0
  31. package/docs/audit-logging.md +2 -0
  32. package/docs/authentication.md +2 -0
  33. package/docs/authorization.md +2 -0
  34. package/docs/certification/02-enterprise-features/lab.md +2 -0
  35. package/docs/certification/02-enterprise-features/lesson.md +2 -0
  36. package/docs/certification/04-production-deployment/lab.md +2 -0
  37. package/docs/certification/04-production-deployment/lesson.md +2 -0
  38. package/docs/control-plane-migration.md +97 -0
  39. package/docs/dashboard-guide.md +6 -7
  40. package/docs/dev/release-checklist.md +9 -11
  41. package/docs/enterprise/integration-cookbook.md +2 -0
  42. package/docs/enterprise/migration.md +1 -1
  43. package/docs/enterprise/performance.md +3 -1
  44. package/docs/enterprise/sdk-guide.md +2 -0
  45. package/docs/enterprise/security.md +2 -0
  46. package/docs/plans/FORGE-AUTONOMOUS-QUEUE.md +2 -2
  47. package/docs/research-2026-07/OBSERVABILITY-TOOL-SPEC.md +1 -1
  48. package/docs/retrospectives/v7.5.15-honesty-audit.md +2 -2
  49. package/docs/test-scenarios/enterprise-scenarios.md +2 -2
  50. package/docs/v10/ARCHITECT-CUTS.md +9 -9
  51. package/docs/v10/BACKLOG.md +7 -7
  52. package/docs/v10/BOARD.md +25 -25
  53. package/docs/v10/CONTROL-PLANE.md +4 -4
  54. package/docs/v10/CP-ENTERPRISE-UI.md +270 -9
  55. package/docs/v10/D51-PHASE-B.md +1 -1
  56. package/docs/v10/DECISIONS.md +3 -3
  57. package/docs/v10/DEPS.md +10 -10
  58. package/docs/v10/FAILURE-CLASSES.md +66 -1
  59. package/docs/v10/LEGACY-REMOVAL.md +2 -2
  60. package/docs/v10/METRICS.md +6 -0
  61. package/docs/v10/SWARM.md +2 -2
  62. package/loki-ts/data/model-pricing.json +6 -6
  63. package/loki-ts/dist/cockpit.js +11 -11
  64. package/loki-ts/dist/loki.js +819 -809
  65. package/loki-ts/package.json +1 -1
  66. package/magic/core/design_tokens.py +1 -2
  67. package/magic/core/generator.py +2 -2
  68. package/magic/tokens/README.md +1 -3
  69. package/mcp/__init__.py +1 -1
  70. package/package.json +3 -8
  71. package/packages/control-plane/dist/server.js +17954 -5696
  72. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  73. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  74. package/packages/control-plane/drizzle/meta/_journal.json +9 -2
  75. package/packages/control-plane/package.json +4 -1
  76. package/packages/control-plane/src/db/schema.ts +19 -0
  77. package/packages/control-plane/src/server/app.ts +11 -22
  78. package/packages/control-plane/src/server/audit.ts +15 -0
  79. package/packages/control-plane/src/server/auth.ts +7 -1
  80. package/packages/control-plane/src/server/legacy/routes-audit.ts +50 -0
  81. package/packages/control-plane/src/server/legacy/routes-control.ts +26 -0
  82. package/packages/control-plane/src/server/legacy/routes-memory.ts +30 -0
  83. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  84. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  85. package/packages/control-plane/src/server/legacy/shim.ts +201 -0
  86. package/packages/control-plane/src/server/repos.ts +27 -0
  87. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  88. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  89. package/packages/control-plane/src/server/routes/checkpoints.ts +171 -0
  90. package/packages/control-plane/src/server/routes/config.ts +319 -0
  91. package/packages/control-plane/src/server/routes/control.ts +75 -0
  92. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  93. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  94. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  95. package/packages/control-plane/src/server/routes/index.ts +71 -0
  96. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  97. package/packages/control-plane/src/server/routes/memory.ts +219 -0
  98. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  99. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  100. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  101. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  102. package/packages/control-plane/src/server/routes/session_control.ts +155 -0
  103. package/packages/control-plane/src/server/routes/start.ts +39 -0
  104. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  105. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  106. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  107. package/packages/control-plane/src/server/spawn.ts +79 -0
  108. package/packages/control-plane/src/shipper/discover.ts +19 -1
  109. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  110. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  111. package/packages/control-plane/ui/dist/index.html +2 -2
  112. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  113. package/references/magic-modules-patterns.md +2 -2
  114. package/skills/factory-operations.md +3 -3
  115. package/skills/magic-modules.md +1 -1
  116. package/skills/release-cadence.md +1 -1
  117. package/tools/audit-docs.py +1 -1
  118. package/tools/index-codebase.py +1 -1
  119. package/web-app/dist/assets/{AdminPage-BJSU5hY1.js → AdminPage-DqMnfZrH.js} +1 -1
  120. package/web-app/dist/assets/{Avatar-CSOSpw9c.js → Avatar-BEof29du.js} +1 -1
  121. package/web-app/dist/assets/{Badge-COr7vj0D.js → Badge-WD53OL5m.js} +1 -1
  122. package/web-app/dist/assets/{Button-DGoWFERp.js → Button-Rzk5flhU.js} +1 -1
  123. package/web-app/dist/assets/{CockpitPage-CBpWkNXY.js → CockpitPage-Bqz3PCMh.js} +1 -1
  124. package/web-app/dist/assets/{ComparePage-BwT8vqrL.js → ComparePage-Byo2NzAZ.js} +1 -1
  125. package/web-app/dist/assets/{ErrorBoundary-D7SicDDA.js → ErrorBoundary-BuE0kH3W.js} +1 -1
  126. package/web-app/dist/assets/{EvidenceReceiptPanel-bx-5liG2.js → EvidenceReceiptPanel-CKFHOPBx.js} +1 -1
  127. package/web-app/dist/assets/{GitHubIssuesPanel-BAeeWLqY.js → GitHubIssuesPanel-Df_kTMqI.js} +1 -1
  128. package/web-app/dist/assets/{GitHubPRsPanel-CssD8lGI.js → GitHubPRsPanel-CouvcKrZ.js} +1 -1
  129. package/web-app/dist/assets/{HomePage-SL5PT3m5.js → HomePage-dEpLzj0x.js} +2 -2
  130. package/web-app/dist/assets/{LoginPage-C8hFOZOM.js → LoginPage-6DVjUND5.js} +1 -1
  131. package/web-app/dist/assets/{MagicPage-xHbHkrPf.js → MagicPage-BDQ9MOfY.js} +1 -1
  132. package/web-app/dist/assets/{MetricsPage-B_2CKFcC.js → MetricsPage-CXiJ04G_.js} +1 -1
  133. package/web-app/dist/assets/{NotFoundPage-CXwXMq35.js → NotFoundPage-BaXhZvyj.js} +1 -1
  134. package/web-app/dist/assets/{ProjectPage-AaisMQI-.js → ProjectPage-BzE4V7Ti.js} +3 -3
  135. package/web-app/dist/assets/{ProjectsPage-Cej5ResX.js → ProjectsPage-ipf6TLMX.js} +1 -1
  136. package/web-app/dist/assets/{SettingsPage-DKv4mn9H.js → SettingsPage-BDxkUKN6.js} +1 -1
  137. package/web-app/dist/assets/{ShowcasePage-Dkiyzne4.js → ShowcasePage-YQXD72SM.js} +1 -1
  138. package/web-app/dist/assets/{SystemSettingsPage-DaBLQ5dU.js → SystemSettingsPage-Dou7zHS0.js} +1 -1
  139. package/web-app/dist/assets/{TeamsPage-B2yUr31k.js → TeamsPage-CQarfcYr.js} +1 -1
  140. package/web-app/dist/assets/{TemplatesPage-CfYiANhU.js → TemplatesPage-CPi1R8Xj.js} +1 -1
  141. package/web-app/dist/assets/{TerminalOutput-CMXoexLM.js → TerminalOutput-WiGhWdYD.js} +1 -1
  142. package/web-app/dist/assets/{activity-B0dzoZtY.js → activity-DOa7ejQA.js} +1 -1
  143. package/web-app/dist/assets/{bell-DFOTaSoo.js → bell-CX-VAlYN.js} +1 -1
  144. package/web-app/dist/assets/{bot-Dz-WSQyP.js → bot-EDXaZmHc.js} +1 -1
  145. package/web-app/dist/assets/{check-L1QIRueJ.js → check-CnBdKk3L.js} +1 -1
  146. package/web-app/dist/assets/{chevron-left-1TrnIEZS.js → chevron-left-BJ_ydRQW.js} +1 -1
  147. package/web-app/dist/assets/{circle-alert-8Bf6xEDx.js → circle-alert-tz8ufhc_.js} +1 -1
  148. package/web-app/dist/assets/{clock-DB8NE6P5.js → clock-DFBZPSsH.js} +1 -1
  149. package/web-app/dist/assets/{cloud-DUSL3wZS.js → cloud-uy3p_5Q7.js} +1 -1
  150. package/web-app/dist/assets/{code-xml-DbjryWNl.js → code-xml-3CbYD9YL.js} +1 -1
  151. package/web-app/dist/assets/{database-DrezrMSs.js → database-YiNBXc9c.js} +1 -1
  152. package/web-app/dist/assets/{dollar-sign-CGbaLMzR.js → dollar-sign-D0wBa4Cv.js} +1 -1
  153. package/web-app/dist/assets/{file-code-corner-Dfypa-r4.js → file-code-corner-DDdZVZ5v.js} +1 -1
  154. package/web-app/dist/assets/{file-plus-CzV5L2Gd.js → file-plus-D09LTBqt.js} +1 -1
  155. package/web-app/dist/assets/{globe-BoYjTwPQ.js → globe-BirTPwff.js} +1 -1
  156. package/web-app/dist/assets/{hammer-WO-HuiyJ.js → hammer-DaE5qgnd.js} +1 -1
  157. package/web-app/dist/assets/{index-CjIOUjnd.js → index-D8t7KnQf.js} +3 -3
  158. package/web-app/dist/assets/{layers-Bn6jtKNk.js → layers-DVVrckRY.js} +1 -1
  159. package/web-app/dist/assets/{loader-circle-gERWRnmW.js → loader-circle-2-nMGmcb.js} +1 -1
  160. package/web-app/dist/assets/{lock-BIiMLJq8.js → lock-D0Xj43pC.js} +1 -1
  161. package/web-app/dist/assets/{package-dLvDSVys.js → package-BB7S3egm.js} +1 -1
  162. package/web-app/dist/assets/{plus-BHM3ZLhZ.js → plus-BLhoMrn3.js} +1 -1
  163. package/web-app/dist/assets/{refresh-cw-BAp_alv6.js → refresh-cw-DMc2uPLw.js} +1 -1
  164. package/web-app/dist/assets/{rotate-ccw-CBhwq_MG.js → rotate-ccw-DM3IP9x7.js} +1 -1
  165. package/web-app/dist/assets/{scroll-text-DoS-6dfU.js → scroll-text-lH2uW7rB.js} +1 -1
  166. package/web-app/dist/assets/{server-5JgdDWQO.js → server-BkerZcdR.js} +1 -1
  167. package/web-app/dist/assets/{shield-alert-DjmlLT34.js → shield-alert-B8ohcUfu.js} +1 -1
  168. package/web-app/dist/assets/{thumbs-up-BtiEXl3p.js → thumbs-up-CTIxLzPD.js} +1 -1
  169. package/web-app/dist/assets/{trash-2-ClsWU_Vg.js → trash-2-C3zIpsH5.js} +1 -1
  170. package/web-app/dist/assets/{trending-down-DPS4M7_X.js → trending-down-BTT7wV4Y.js} +1 -1
  171. package/web-app/dist/assets/{trending-up-D6OJ38-V.js → trending-up-BzHdCDvF.js} +1 -1
  172. package/web-app/dist/assets/{upload-DOCzLUpF.js → upload-HxR34-A8.js} +1 -1
  173. package/web-app/dist/assets/{usePolling-CljQTWj_.js → usePolling-DDj6Fupy.js} +1 -1
  174. package/web-app/dist/assets/{user-C0G82Weu.js → user-qH5y9yNy.js} +1 -1
  175. package/web-app/dist/index.html +1 -1
  176. package/dashboard/static/assets/mermaid.min.js +0 -2030
  177. package/dashboard/static/cost.html +0 -344
  178. package/dashboard/static/favicon.svg +0 -5
  179. package/dashboard/static/index.html +0 -15577
  180. package/dashboard/static/proofs.html +0 -194
  181. package/dashboard/static/start.html +0 -131
  182. package/dashboard/static/trust.html +0 -325
  183. package/packages/control-plane/src/server/actions.ts +0 -60
  184. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +0 -1
  185. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +0 -129
@@ -0,0 +1,210 @@
1
+ // CPE24-P1: the per-checkout cost ledger, ported from dashboard/server.py (/api/cost, /api/cost/timeline). Same on-disk sources:
2
+ // <repo>/.loki/metrics/efficiency/iteration-*.json, metrics/budget.json, context/tracking.json, proofs/<run>/proof.json, pricing.json.
3
+ // Honest data: a present record is not a measurement. Anything not measured is null, never a summed 0.
4
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { probeAllowed, type RouteCtx } from "./index.ts";
7
+
8
+ type Rec = Record<string, unknown>;
9
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
10
+ const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
11
+ const r6 = (n: number) => Math.round(n * 1e6) / 1e6;
12
+
13
+ export const MEASURED_FIELDS = ["cost_usd", "input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens"] as const;
14
+ export const BUDGET_WARN_FRACTION = 0.8;
15
+ export const NOT_MEASURED = "not measured";
16
+
17
+ export function readJson(path: string): unknown {
18
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
19
+ }
20
+
21
+ /** One record carries an observed (non-zero numeric) value in at least one measured field; mirrors autonomy/lib/efficiency_cost.py record_is_measured. */
22
+ export function recordIsMeasured(rec: unknown, skip?: string): boolean {
23
+ if (!isObj(rec)) return false;
24
+ return MEASURED_FIELDS.some((k) => k !== skip && isNum(rec[k]) && rec[k] !== 0);
25
+ }
26
+
27
+ const DEFAULT_PRICING: Record<string, Rec> = {
28
+ fable: { input: 10, output: 50 }, "claude-fable-5": { input: 10, output: 50 },
29
+ opus: { input: 5, output: 25 }, sonnet: { input: 2, output: 10 }, haiku: { input: 1, output: 5 },
30
+ "gpt-5.3-codex": { input: 1.5, output: 12 },
31
+ "gpt-5.6-sol": { input: 2.5, output: 20 }, "gpt-5.6-terra": { input: 1.5, output: 12 }, "gpt-5.6-luna": { input: 0.5, output: 4 },
32
+ };
33
+
34
+ function pricing(lokiDir: string): Record<string, Rec> {
35
+ const d = readJson(join(lokiDir, "pricing.json"));
36
+ const models = isObj(d) && isObj(d.models) ? (d.models as Record<string, Rec>) : {};
37
+ return { ...DEFAULT_PRICING, ...models };
38
+ }
39
+
40
+ /** Token-priced USD with cache tiers; an unpriced cache tier falls back to the input rate (over-state, never under-count). */
41
+ export function modelCost(table: Record<string, Rec>, model: string, inp: number, out: number, cr = 0, cw = 0): number {
42
+ const p = table[model.toLowerCase()] ?? table.sonnet ?? {};
43
+ const ir = isNum(p.input) ? p.input : 3;
44
+ const or = isNum(p.output) ? p.output : 15;
45
+ const crr = isNum(p.cache_read) ? p.cache_read : ir * 0.1;
46
+ const cwr = isNum(p.cache_write) ? p.cache_write : ir * 1.25;
47
+ return r6((inp * ir + out * or + cr * crr + cw * cwr) / 1e6);
48
+ }
49
+
50
+ /** Parsed iteration-*.json records in filename order (the file set the budget breaker reads). */
51
+ export function iterationRecords(lokiDir: string): Rec[] {
52
+ const dir = join(lokiDir, "metrics", "efficiency");
53
+ let names: string[] = [];
54
+ try { names = readdirSync(dir).filter((n) => /^iteration-.*\.json$/.test(n)).sort(); } catch { return []; }
55
+ const out: Rec[] = [];
56
+ for (const n of names) { const d = readJson(join(dir, n)); if (isObj(d)) out.push(d); }
57
+ return out;
58
+ }
59
+
60
+ const num0 = (v: unknown) => (isNum(v) ? v : 0);
61
+ const priced = (table: Record<string, Rec>, d: Rec) =>
62
+ d.cost_usd === null || d.cost_usd === undefined
63
+ ? modelCost(table, String(d.model ?? "sonnet").toLowerCase(), num0(d.input_tokens), num0(d.output_tokens), num0(d.cache_read_tokens), num0(d.cache_creation_tokens))
64
+ : num0(d.cost_usd);
65
+
66
+ interface Bucket { input_tokens: number; output_tokens: number; cost_usd: number; measured: boolean; tokens_measured: boolean }
67
+
68
+ export function costSnapshot(lokiDir: string) {
69
+ const table = pricing(lokiDir);
70
+ let tIn = 0, tOut = 0, tCr = 0, tCw = 0, est = 0;
71
+ let recorded = false, unknownUsd = false;
72
+ const byPhase: Record<string, Bucket> = {}, byModel: Record<string, Bucket> = {};
73
+ for (const d of iterationRecords(lokiDir)) {
74
+ const measured = recordIsMeasured(d);
75
+ if (measured) recorded = true;
76
+ const tokMeasured = recordIsMeasured(d, "cost_usd");
77
+ const inp = num0(d.input_tokens), out = num0(d.output_tokens), cr = num0(d.cache_read_tokens), cw = num0(d.cache_creation_tokens);
78
+ const model = String(d.model ?? "sonnet").toLowerCase();
79
+ const phase = String(d.phase ?? "unknown");
80
+ tIn += inp; tOut += out; tCr += cr; tCw += cw;
81
+ const cost = priced(table, d);
82
+ est += cost;
83
+ for (const [bucket, name] of [[byPhase, phase], [byModel, model]] as const) {
84
+ const b = (bucket[name] ??= { input_tokens: 0, output_tokens: 0, cost_usd: 0, measured: false, tokens_measured: false });
85
+ b.input_tokens += inp; b.output_tokens += out; b.cost_usd += cost;
86
+ b.measured ||= measured; b.tokens_measured ||= tokMeasured;
87
+ }
88
+ }
89
+ if (tIn === 0 && tOut === 0) {
90
+ const ctx = readJson(join(lokiDir, "context", "tracking.json"));
91
+ const totals = isObj(ctx) && isObj(ctx.totals) ? ctx.totals : {};
92
+ tIn = num0(totals.total_input); tOut = num0(totals.total_output);
93
+ if (tIn > 0 || tOut > 0) {
94
+ recorded = true;
95
+ if (isNum(totals.total_cost_usd)) est = totals.total_cost_usd; else unknownUsd = true;
96
+ const model = String((isObj(ctx) && ctx.provider) || "unknown").toLowerCase();
97
+ const per = isObj(ctx) && Array.isArray(ctx.per_iteration) ? ctx.per_iteration : [];
98
+ for (const it of per) {
99
+ if (!isObj(it)) continue;
100
+ const b = (byModel[model] ??= { input_tokens: 0, output_tokens: 0, cost_usd: 0, measured: false, tokens_measured: true });
101
+ b.input_tokens += num0(it.input_tokens); b.output_tokens += num0(it.output_tokens);
102
+ if (isNum(it.cost_usd)) { b.cost_usd += it.cost_usd; b.measured = true; }
103
+ }
104
+ }
105
+ }
106
+ let limit: number | null = null, used: number | null = null, remaining: number | null = null;
107
+ const bd = readJson(join(lokiDir, "metrics", "budget.json"));
108
+ if (isObj(bd) && isNum(bd.limit)) {
109
+ limit = bd.limit;
110
+ if (recorded && !unknownUsd) { used = est; remaining = Math.max(0, limit - used); }
111
+ }
112
+ const readIn = tIn + tCr;
113
+ const fmt = (m: Record<string, Bucket>) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, {
114
+ input_tokens: v.tokens_measured ? v.input_tokens : null, output_tokens: v.tokens_measured ? v.output_tokens : null,
115
+ cost_usd: v.measured ? r6(v.cost_usd) : null,
116
+ }]));
117
+ return {
118
+ total_input_tokens: recorded ? tIn : null, total_output_tokens: recorded ? tOut : null,
119
+ total_cache_read_tokens: recorded ? tCr : null, total_cache_creation_tokens: recorded ? tCw : null,
120
+ total_tokens: recorded ? tIn + tOut + tCr + tCw : null,
121
+ cache_hit_ratio: readIn > 0 ? Math.round((tCr / readIn) * 1e4) / 1e4 : null,
122
+ estimated_cost_usd: recorded && !unknownUsd ? r6(est) : null,
123
+ cost_recorded: recorded,
124
+ by_phase: fmt(byPhase), by_model: fmt(byModel),
125
+ budget_limit: limit, budget_used: limit !== null && used !== null ? r6(used) : null, budget_remaining: remaining !== null ? r6(remaining) : null,
126
+ ...(recorded ? {} : { not_measured: NOT_MEASURED }),
127
+ };
128
+ }
129
+
130
+ export function budgetStatus(used: number | null, limit: number | null): "none" | "unknown" | "ok" | "warn" | "exceeded" {
131
+ if (limit === null || limit <= 0) return "none";
132
+ if (used === null) return "unknown";
133
+ if (used >= limit) return "exceeded";
134
+ return used >= BUDGET_WARN_FRACTION * limit ? "warn" : "ok";
135
+ }
136
+
137
+ export function budgetSnapshot(lokiDir: string) {
138
+ const table = pricing(lokiDir);
139
+ let total = 0, recorded = false, unmeasured = 0;
140
+ for (const d of iterationRecords(lokiDir)) {
141
+ if (recordIsMeasured(d)) recorded = true; else unmeasured++;
142
+ total += priced(table, d);
143
+ }
144
+ let limit: number | null = null;
145
+ const bd = readJson(join(lokiDir, "metrics", "budget.json"));
146
+ if (isObj(bd)) { const l = bd.limit || bd.budget_limit; limit = isNum(l) ? l : null; }
147
+ if (limit === null && process.env.LOKI_BUDGET_LIMIT) { const e = Number(process.env.LOKI_BUDGET_LIMIT); if (Number.isFinite(e)) limit = e; }
148
+ const used = recorded ? r6(total) : null;
149
+ const ok = limit !== null && limit > 0 && used !== null;
150
+ const status = budgetStatus(used, limit);
151
+ return {
152
+ limit, used, remaining: ok ? r6(Math.max(0, limit! - used!)) : null, percent_used: ok ? Math.round((used! / limit!) * 1e4) / 100 : null,
153
+ status, warn_threshold_percent: Math.round(BUDGET_WARN_FRACTION * 100), exceeded: status === "exceeded", partial: recorded && unmeasured > 0,
154
+ };
155
+ }
156
+
157
+ export function costTimeline(lokiDir: string) {
158
+ const table = pricing(lokiDir);
159
+ const recs = iterationRecords(lokiDir).map((d, i) => ({ d, i }))
160
+ .sort((a, b) => (num0(Number(a.d.iteration)) - num0(Number(b.d.iteration))) || a.i - b.i).map((x) => x.d);
161
+ const iterations: Rec[] = [];
162
+ let cum = 0, recorded = false, unmeasured = 0;
163
+ for (const d of recs) {
164
+ const measured = recordIsMeasured(d);
165
+ if (measured) recorded = true; else unmeasured++;
166
+ const model = String(d.model ?? "sonnet").toLowerCase();
167
+ cum += priced(table, d);
168
+ iterations.push({
169
+ iteration: d.iteration ?? null, timestamp: d.timestamp ?? null, model: d.model ? model : null, phase: d.phase ?? "unknown", provider: d.provider ?? null,
170
+ input_tokens: measured ? num0(d.input_tokens) : null, output_tokens: measured ? num0(d.output_tokens) : null,
171
+ cost_usd: measured ? r6(priced(table, d)) : null, cumulative_usd: measured ? r6(cum) : null,
172
+ });
173
+ }
174
+ const runs: Rec[] = [];
175
+ let projectTotal = 0, runsMeasured = 0;
176
+ const proofs = join(lokiDir, "proofs");
177
+ let entries: string[] = [];
178
+ try { entries = readdirSync(proofs).sort(); } catch { /* none */ }
179
+ for (const e of entries) {
180
+ try { if (!statSync(join(proofs, e)).isDirectory()) continue; } catch { continue; }
181
+ const p = readJson(join(proofs, e, "proof.json"));
182
+ if (!isObj(p)) continue;
183
+ const cost = isObj(p.cost) ? p.cost : {};
184
+ const usd = isNum(cost.usd) ? cost.usd : null;
185
+ if (usd !== null) { projectTotal += usd; runsMeasured++; }
186
+ runs.push({
187
+ run_id: p.run_id ?? e, generated_at: p.generated_at ?? null, model: isObj(p.provider) ? (p.provider.model ?? null) : null,
188
+ cost_usd: usd !== null ? r6(usd) : null, cost_partial: cost.cost_partial === true,
189
+ files_changed: isObj(p.files_changed) ? (p.files_changed.count ?? null) : null, final_verdict: isObj(p.council) ? (p.council.final_verdict ?? null) : null,
190
+ });
191
+ }
192
+ runs.sort((a, b) => String(b.generated_at ?? "").localeCompare(String(a.generated_at ?? "")));
193
+ return {
194
+ current_run: { iterations, total_usd: recorded ? r6(cum) : null, cost_recorded: recorded, partial: recorded && unmeasured > 0, unmeasured_iterations: unmeasured },
195
+ runs, runs_count: runs.length,
196
+ project_total_usd: runsMeasured ? r6(projectTotal) : null, project_total_partial: runsMeasured > 0 && runsMeasured < runs.length,
197
+ budget: budgetSnapshot(lokiDir),
198
+ ...(recorded || runsMeasured ? {} : { not_measured: NOT_MEASURED }),
199
+ };
200
+ }
201
+
202
+ export const lokiDirOf = (repoDir: string) => join(repoDir, ".loki");
203
+ export const lokiDirExists = (repoDir: string) => existsSync(lokiDirOf(repoDir));
204
+
205
+ export function mount(ctx: RouteCtx): void {
206
+ // Disk-reading routes: with a token the bearer (tokenGuard) is the gate; without one they are loopback-only, like the probe routes.
207
+ const deny = { error: "loopback only without a token" };
208
+ ctx.app.get("/v1/cost/snapshot", (c) => (probeAllowed(ctx, c) ? c.json(costSnapshot(lokiDirOf(ctx.repoDir))) : c.json(deny, 403)));
209
+ ctx.app.get("/v1/cost/timeline", (c) => (probeAllowed(ctx, c) ? c.json(costTimeline(lokiDirOf(ctx.repoDir))) : c.json(deny, 403)));
210
+ }
@@ -0,0 +1,101 @@
1
+ // CPE24-P1: fleet spend, ported from dashboard/registry.py (get_fleet_runs / get_fleet_summary). Same sources: the machine registry
2
+ // ~/.loki/dashboard/projects.json plus each project's .loki/ (dashboard-state.json, session.json, metrics/efficiency/*.json, context/tracking.json).
3
+ // A run with no measured cost has cost_usd null; the summed total is null when none measured and flagged partial when some did not.
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, join } from "node:path";
7
+ import { probeAllowed, type RouteCtx } from "./index.ts";
8
+ import { NOT_MEASURED, readJson, recordIsMeasured } from "./cost_ledger.ts";
9
+
10
+ type Rec = Record<string, unknown>;
11
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
12
+ const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
13
+
14
+ export const registryFile = () => join(process.env.HOME ?? homedir(), ".loki", "dashboard", "projects.json");
15
+
16
+ export function pidAlive(pid: unknown): boolean {
17
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
18
+ try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; }
19
+ }
20
+
21
+ export function projectSnapshot(path: string) {
22
+ const snap = { phase: "", iteration: null as number | null, cost_usd: null as number | null, started_at: null as string | null, ended_at: null as string | null };
23
+ if (!path) return snap;
24
+ const loki = join(path, ".loki");
25
+ const st = readJson(join(loki, "dashboard-state.json"));
26
+ if (isObj(st)) { snap.phase = typeof st.phase === "string" ? st.phase : ""; snap.iteration = Number.isInteger(st.iteration) ? (st.iteration as number) : null; }
27
+ const ss = readJson(join(loki, "session.json"));
28
+ if (isObj(ss)) {
29
+ const a = ss.startedAt || ss.started_at, b = ss.endedAt || ss.ended_at;
30
+ snap.started_at = typeof a === "string" ? a : null;
31
+ snap.ended_at = typeof b === "string" ? b : null;
32
+ }
33
+ let cost = 0, found = false;
34
+ const eff = join(loki, "metrics", "efficiency");
35
+ let names: string[] = [];
36
+ try { names = readdirSync(eff).filter((n) => n.endsWith(".json")); } catch { /* none */ }
37
+ for (const n of names) {
38
+ const d = readJson(join(eff, n));
39
+ if (isObj(d) && isNum(d.cost_usd) && recordIsMeasured(d)) { cost += d.cost_usd; found = true; }
40
+ }
41
+ if (!found) {
42
+ const t = readJson(join(loki, "context", "tracking.json"));
43
+ const totals = isObj(t) && isObj(t.totals) ? t.totals : null;
44
+ if (totals && isNum(totals.total_cost_usd) && recordIsMeasured({ cost_usd: totals.total_cost_usd, input_tokens: totals.total_input, output_tokens: totals.total_output })) {
45
+ cost = totals.total_cost_usd; found = true;
46
+ }
47
+ }
48
+ snap.cost_usd = found ? Math.round(cost * 1e6) / 1e6 : null;
49
+ return snap;
50
+ }
51
+
52
+ export function fleetRuns(includeInactive = true, file = registryFile()) {
53
+ let projects: Rec[] = [];
54
+ try {
55
+ const reg = JSON.parse(readFileSync(file, "utf8"));
56
+ if (isObj(reg) && isObj(reg.projects)) projects = Object.values(reg.projects).filter(isObj);
57
+ } catch { return []; }
58
+ if (!includeInactive) projects = projects.filter((p) => p.status === "active");
59
+ const out = projects.map((p) => {
60
+ const path = typeof p.path === "string" ? p.path : "";
61
+ const running = pidAlive(p.pid);
62
+ const snap = projectSnapshot(path);
63
+ const reg = String(p.status ?? "unknown").toLowerCase();
64
+ const status = running ? "running" : ["running", "building", "active", "in_progress"].includes(reg) ? "stale" : (p.status ?? "unknown");
65
+ let duration: number | null = null;
66
+ if (snap.started_at) {
67
+ const s = Date.parse(snap.started_at);
68
+ if (!Number.isNaN(s)) {
69
+ const e = snap.ended_at && !running ? Date.parse(snap.ended_at) : NaN;
70
+ duration = Math.max(0, Math.floor(((Number.isNaN(e) ? Date.now() : e) - s) / 1000));
71
+ }
72
+ }
73
+ return {
74
+ id: p.id ?? null, name: (p.name as string) || (path ? basename(path) : "project"), path, status, running, phase: snap.phase,
75
+ iteration: snap.iteration, cost_usd: snap.cost_usd, started_at: snap.started_at, duration_seconds: duration, port: p.port ?? null,
76
+ };
77
+ });
78
+ out.sort((a, b) => (a.running === b.running ? String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")) : a.running ? -1 : 1));
79
+ return out;
80
+ }
81
+
82
+ export function fleetSummary(includeInactive = true, file = registryFile()) {
83
+ const runs = fleetRuns(includeInactive, file);
84
+ const costs = runs.map((r) => r.cost_usd).filter((c): c is number => isNum(c));
85
+ const running = runs.filter((r) => r.running).length;
86
+ return {
87
+ total_runs: runs.length, running_runs: running, stopped_runs: runs.length - running,
88
+ total_cost_usd: costs.length ? Math.round(costs.reduce((a, b) => a + b, 0) * 1e6) / 1e6 : null,
89
+ total_cost_partial: costs.length > 0 && costs.length < runs.length,
90
+ ...(costs.length ? {} : { not_measured: NOT_MEASURED }),
91
+ };
92
+ }
93
+
94
+ export const parseInactive = (v: string | undefined) => !["false", "0", "no"].includes((v ?? "true").toLowerCase());
95
+
96
+ export function mount(ctx: RouteCtx): void {
97
+ const deny = { error: "loopback only without a token" };
98
+ // /v1 never exposes a project's filesystem path.
99
+ ctx.app.get("/v1/fleet/runs", (c) => (probeAllowed(ctx, c) ? c.json(fleetRuns(parseInactive(c.req.query("include_inactive"))).map(({ path: _p, ...r }) => r)) : c.json(deny, 403)));
100
+ ctx.app.get("/v1/fleet/summary", (c) => (probeAllowed(ctx, c) ? c.json(fleetSummary(parseInactive(c.req.query("include_inactive")))) : c.json(deny, 403)));
101
+ }
@@ -0,0 +1,71 @@
1
+ // Route registry. Every planned route file has a stub module here, so later slices (CPE-04 onward) only fill their own file.
2
+ // `app` serves everywhere; `act` is the loopback-only router (the same Hono as `app` on a loopback bind, a detached one otherwise, so its routes are never registered).
3
+ import type { Context, Hono } from "hono";
4
+ import type { Db } from "../../db/migrate.ts";
5
+ import type { spawnStart } from "../spawn.ts";
6
+ import { isLoopbackHost } from "../auth.ts";
7
+ import { mountRepos } from "../repos.ts";
8
+ import { mount as artifacts } from "./artifacts.ts";
9
+ import { mount as audit } from "./audit.ts";
10
+ import { mount as checkpoints } from "./checkpoints.ts";
11
+ import { mount as config } from "./config.ts";
12
+ import { mount as control } from "./control.ts";
13
+ import { mount as cost } from "./cost.ts";
14
+ import { mount as costLedger } from "./cost_ledger.ts";
15
+ import { mount as fleet } from "./fleet.ts";
16
+ import { mount as memory } from "./memory.ts";
17
+ import { mount as metrics } from "./metrics.ts";
18
+ import { mount as sessionControl } from "./session_control.ts";
19
+ import { mount as integrations } from "./integrations.ts";
20
+ import { mount as mergeRisk } from "./merge_risk.ts";
21
+ import { mount as notify } from "./notify.ts";
22
+ import { mount as providers } from "./providers.ts";
23
+ import { mount as start } from "./start.ts";
24
+ import { mount as stats } from "./stats.ts";
25
+ import { mount as stream } from "./stream.ts";
26
+ import { mount as verify } from "./verify.ts";
27
+
28
+ export interface RouteCtx {
29
+ app: Hono;
30
+ act: Hono;
31
+ db: Db;
32
+ repoDir: string;
33
+ /** Bearer token when one is set (tokenGuard already enforces it on /v1/*). */
34
+ token?: string;
35
+ /** The real socket peer is loopback (not the spoofable Host header; unknown peer fails closed). */
36
+ peerIsLoopback: (c: Context) => boolean;
37
+ /** peerIsLoopback plus a loopback Host plus a JSON content type: the guard for state-changing actions. */
38
+ local: (c: Context) => boolean;
39
+ /** Binary for spawned runs (default `loki`) and a spawn seam for tests. */
40
+ startBin?: string;
41
+ spawnImpl?: typeof spawnStart;
42
+ /** Where BLOCKED answers are written (createApp answerDir). */
43
+ answerDir?: string;
44
+ }
45
+
46
+ export const routeModules: ReadonlyArray<(ctx: RouteCtx) => void> = [artifacts, stream, start, control, stats, cost, costLedger, fleet, metrics, checkpoints, memory, sessionControl, config, providers, verify, integrations, notify, audit, mergeRisk];
47
+
48
+ export function registerRoutes(ctx: RouteCtx): void {
49
+ mountRepos(ctx.act, ctx.db, ctx.peerIsLoopback);
50
+ for (const m of routeModules) m(ctx);
51
+ }
52
+
53
+ /** Spawning/probing read routes: with a token set the bearer (enforced by tokenGuard) is the gate; without one they are loopback-only (peer and Host), like the act routes. */
54
+ export function probeAllowed(ctx: Pick<RouteCtx, "token" | "peerIsLoopback">, c: Context): boolean {
55
+ return !!ctx.token || (ctx.peerIsLoopback(c) && isLoopbackHost(c.req.header("host")));
56
+ }
57
+
58
+ export const PROBE_CACHE_MS = 60_000;
59
+
60
+ /** One cached value per key for ttlMs; concurrent callers share the in-flight promise so a burst spawns once. */
61
+ export function ttlCache<T>(ttlMs = PROBE_CACHE_MS, now: () => number = Date.now): (key: string, load: () => Promise<T>) => Promise<T> {
62
+ const m = new Map<string, { at: number; p: Promise<T> }>();
63
+ return (key, load) => {
64
+ const hit = m.get(key);
65
+ if (hit && now() - hit.at < ttlMs) return hit.p;
66
+ const p = load();
67
+ m.set(key, { at: now(), p });
68
+ p.catch(() => { if (m.get(key)?.p === p) m.delete(key); });
69
+ return p;
70
+ };
71
+ }
@@ -0,0 +1,123 @@
1
+ // CPE-20: GET /v1/integrations. Presence-only probes: an env var NAME and a boolean, or a CLI exit code. No secret value is read into a
2
+ // response, logged or returned. Connect is not here: the UI writes the env var NAME through PUT /v1/config (CPE-14), the only writer.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { delimiter, join } from "node:path";
6
+ import { parseDocument } from "yaml";
7
+ import { resolveConfigFile } from "./config.ts";
8
+ import { probeAllowed, ttlCache, type RouteCtx } from "./index.ts";
9
+
10
+ const GH_TIMEOUT_MS = 3000;
11
+ const isSet = (n: string | null | undefined): boolean => !!n && (process.env[n] ?? "") !== "";
12
+
13
+ export type Status = "connected" | "not_connected" | "not_measured";
14
+ export interface IntegrationRow {
15
+ id: string;
16
+ label: string;
17
+ status: Status;
18
+ /** How the status was decided: "gh_cli", "env", "mcp_file" or "none". */
19
+ method: string;
20
+ /** The env var NAME checked (never a value), or null. */
21
+ env_name: string | null;
22
+ env_set: boolean;
23
+ /** Dotted loki.yaml path that Connect writes, or null when loki.yaml has no slot for this service. */
24
+ config_path: string | null;
25
+ detail: string;
26
+ }
27
+
28
+ /** Exit status of `gh auth status` with output discarded; null when gh is absent, slow or fails to start. */
29
+ export function ghAuthed(pathVar = process.env.PATH ?? "", timeoutMs = GH_TIMEOUT_MS): Promise<boolean | null> {
30
+ const exe = pathVar.split(delimiter).filter(Boolean).map((d) => join(d, "gh")).find((p) => existsSync(p));
31
+ if (!exe) return Promise.resolve(null);
32
+ return new Promise((resolve) => {
33
+ let done = false;
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ const finish = (v: boolean | null): void => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
36
+ let child: ReturnType<typeof spawn>;
37
+ try { child = spawn(exe, ["auth", "status"], { stdio: "ignore", env: { ...process.env, NO_COLOR: "1" } }); } catch { return resolve(null); }
38
+ timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } finish(null); }, timeoutMs);
39
+ child.on("error", () => finish(null));
40
+ child.on("close", (code) => finish(code === 0));
41
+ });
42
+ }
43
+
44
+ function configuredName(repoDir: string, path: string[]): string | null {
45
+ try {
46
+ const r = resolveConfigFile(repoDir);
47
+ if (!r.ok || !r.exists || statSync(r.file).size > 1_000_000) return null;
48
+ const doc = parseDocument(readFileSync(r.file, "utf8"));
49
+ if (doc.errors.length) return null;
50
+ const v = doc.getIn(path);
51
+ return typeof v === "string" && /^[A-Z_][A-Z0-9_]*$/.test(v) ? v : null;
52
+ } catch { return null; }
53
+ }
54
+
55
+ /** MCP servers declared in the repo's .mcp.json: a count only, never their env or args. */
56
+ function mcpServers(repoDir: string): number | null {
57
+ try {
58
+ const p = join(repoDir, ".mcp.json");
59
+ if (!existsSync(p) || statSync(p).size > 1_000_000) return null;
60
+ const j = JSON.parse(readFileSync(p, "utf8")) as { mcpServers?: Record<string, unknown> };
61
+ return j.mcpServers && typeof j.mcpServers === "object" ? Object.keys(j.mcpServers).length : null;
62
+ } catch { return null; }
63
+ }
64
+
65
+ /** Services loki.yaml has no key for: only conventional env var names are checked, and Connect is not offered. */
66
+ const CONVENTIONAL: Array<{ id: string; label: string; names: string[] }> = [
67
+ { id: "gitlab", label: "GitLab", names: ["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"] },
68
+ { id: "jira", label: "Jira", names: ["JIRA_API_TOKEN", "JIRA_TOKEN"] },
69
+ { id: "linear", label: "Linear", names: ["LINEAR_API_KEY"] },
70
+ { id: "sentry", label: "Sentry", names: ["SENTRY_AUTH_TOKEN"] },
71
+ ];
72
+
73
+ function conv(s: { id: string; label: string; names: string[] }): IntegrationRow {
74
+ const hit = s.names.find((x) => isSet(x)) ?? null;
75
+ return {
76
+ id: s.id, label: s.label, status: hit ? "connected" : "not_measured", method: hit ? "env" : "none",
77
+ env_name: hit, env_set: !!hit, config_path: null,
78
+ detail: hit ? `${hit} is set in the environment` : `${s.names.join(" or ")} is not set; only presence can be probed, so this is not measured`,
79
+ };
80
+ }
81
+
82
+ export async function listIntegrations(repoDir: string, gh: () => Promise<boolean | null> = () => ghAuthed()): Promise<IntegrationRow[]> {
83
+ const rows: IntegrationRow[] = [];
84
+
85
+ const gitEnv = configuredName(repoDir, ["git", "token_env"]);
86
+ const ghOk = await gh();
87
+ const gitEnvSet = isSet(gitEnv);
88
+ rows.push({
89
+ id: "github", label: "GitHub",
90
+ status: gitEnvSet || ghOk === true ? "connected" : ghOk === null && !gitEnv ? "not_measured" : "not_connected",
91
+ method: gitEnvSet ? "env" : ghOk !== null ? "gh_cli" : "none",
92
+ env_name: gitEnv, env_set: gitEnvSet, config_path: "git.token_env",
93
+ detail: gitEnvSet ? "git.token_env is set in the environment" : ghOk === true ? "gh auth status succeeded" : ghOk === false ? "gh is installed but not authenticated" : gitEnv ? "git.token_env names a variable that is not set" : "gh not found and git.token_env is not configured",
94
+ });
95
+
96
+ rows.push(conv(CONVENTIONAL[0]!));
97
+
98
+ const slackEnv = configuredName(repoDir, ["notifications", "slack_webhook_env"]);
99
+ const slackSet = isSet(slackEnv);
100
+ rows.push({
101
+ id: "slack", label: "Slack", status: slackSet ? "connected" : "not_connected",
102
+ method: slackEnv ? "env" : "none", env_name: slackEnv, env_set: slackSet, config_path: "notifications.slack_webhook_env",
103
+ detail: !slackEnv ? "notifications.slack_webhook_env is not configured" : slackSet ? "webhook variable is set" : "webhook variable is named but not set",
104
+ });
105
+
106
+ rows.push(...CONVENTIONAL.slice(1).map(conv));
107
+
108
+ const n = mcpServers(repoDir);
109
+ rows.push({
110
+ id: "mcp", label: "MCP", status: n === null ? "not_measured" : n > 0 ? "connected" : "not_connected", method: n === null ? "none" : "mcp_file",
111
+ env_name: null, env_set: false, config_path: null,
112
+ detail: n === null ? "no readable .mcp.json in the repo" : `${n} server${n === 1 ? "" : "s"} declared in .mcp.json`,
113
+ });
114
+ return rows;
115
+ }
116
+
117
+ export function mount(ctx: RouteCtx): void {
118
+ const cached = ttlCache<Awaited<ReturnType<typeof listIntegrations>>>();
119
+ ctx.app.get("/v1/integrations", async (c) => {
120
+ if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
121
+ return c.json({ integrations: await cached(ctx.repoDir, () => listIntegrations(ctx.repoDir)) });
122
+ });
123
+ }