selftune 0.2.22 → 0.2.24

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 (270) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +95 -15
  3. package/apps/local-dashboard/dist/assets/index-DgY2KGP-.css +1 -0
  4. package/apps/local-dashboard/dist/assets/index-Dmx7LPVX.js +15 -0
  5. package/apps/local-dashboard/dist/assets/vendor-react-C5oyHiV1.js +11 -0
  6. package/apps/local-dashboard/dist/assets/{vendor-table-BIiI3YhS.js → vendor-table-Bc_bbKd8.js} +1 -1
  7. package/apps/local-dashboard/dist/assets/vendor-ui-B3BPIYy7.js +1 -0
  8. package/apps/local-dashboard/dist/index.html +5 -5
  9. package/cli/selftune/adapters/codex/install.ts +310 -78
  10. package/cli/selftune/adapters/opencode/install.ts +3 -4
  11. package/cli/selftune/adapters/pi/hook.ts +273 -0
  12. package/cli/selftune/adapters/pi/install.ts +207 -0
  13. package/cli/selftune/alpha-upload/build-payloads.ts +3 -3
  14. package/cli/selftune/alpha-upload/stage-canonical.ts +17 -11
  15. package/cli/selftune/auto-update.ts +200 -8
  16. package/cli/selftune/canonical-export.ts +55 -25
  17. package/cli/selftune/command-surface.ts +397 -0
  18. package/cli/selftune/constants.ts +10 -1
  19. package/cli/selftune/contribute/contribute.ts +64 -13
  20. package/cli/selftune/contribution-config.ts +57 -3
  21. package/cli/selftune/contribution-preferences.ts +117 -0
  22. package/cli/selftune/contribution-signals.ts +8 -4
  23. package/cli/selftune/contribution-staging.ts +13 -2
  24. package/cli/selftune/contributions.ts +55 -121
  25. package/cli/selftune/creator-contributions.ts +29 -10
  26. package/cli/selftune/cron/setup.ts +7 -3
  27. package/cli/selftune/dashboard-contract.ts +87 -0
  28. package/cli/selftune/dashboard-server.ts +168 -17
  29. package/cli/selftune/dashboard.ts +350 -17
  30. package/cli/selftune/eval/baseline.ts +21 -5
  31. package/cli/selftune/eval/execution-eval.ts +170 -0
  32. package/cli/selftune/eval/family-overlap.ts +2 -2
  33. package/cli/selftune/eval/hooks-to-evals.ts +228 -82
  34. package/cli/selftune/eval/import-skillsbench.ts +2 -2
  35. package/cli/selftune/eval/invocation-classifier.ts +56 -0
  36. package/cli/selftune/eval/synthetic-evals.ts +5 -3
  37. package/cli/selftune/eval/unit-test-cli.ts +7 -4
  38. package/cli/selftune/evolution/apply-proposal.ts +295 -0
  39. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  40. package/cli/selftune/evolution/engines/replay-engine.ts +180 -0
  41. package/cli/selftune/evolution/evidence.ts +2 -6
  42. package/cli/selftune/evolution/evolve-body.ts +152 -38
  43. package/cli/selftune/evolution/evolve.ts +244 -52
  44. package/cli/selftune/evolution/rollback.ts +0 -1
  45. package/cli/selftune/evolution/validate-body.ts +111 -49
  46. package/cli/selftune/evolution/validate-host-replay.ts +510 -60
  47. package/cli/selftune/evolution/validate-proposal.ts +11 -150
  48. package/cli/selftune/evolution/validate-routing.ts +51 -108
  49. package/cli/selftune/evolution/validation-contract.ts +91 -0
  50. package/cli/selftune/grading/auto-grade.ts +11 -7
  51. package/cli/selftune/grading/grade-session.ts +10 -16
  52. package/cli/selftune/hooks/skill-eval.ts +2 -1
  53. package/cli/selftune/hooks-shared/types.ts +1 -0
  54. package/cli/selftune/index.ts +58 -15
  55. package/cli/selftune/ingestors/claude-replay.ts +15 -10
  56. package/cli/selftune/ingestors/codex-wrapper.ts +3 -3
  57. package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
  58. package/cli/selftune/ingestors/pi-ingest.ts +727 -0
  59. package/cli/selftune/init.ts +38 -4
  60. package/cli/selftune/localdb/direct-write.ts +120 -1
  61. package/cli/selftune/localdb/materialize.ts +6 -7
  62. package/cli/selftune/localdb/queries/cron.ts +34 -0
  63. package/cli/selftune/localdb/queries/dashboard.ts +834 -0
  64. package/cli/selftune/localdb/queries/evolution.ts +158 -0
  65. package/cli/selftune/localdb/queries/execution.ts +133 -0
  66. package/cli/selftune/localdb/queries/json.ts +18 -0
  67. package/cli/selftune/localdb/queries/monitoring.ts +263 -0
  68. package/cli/selftune/localdb/queries/raw.ts +95 -0
  69. package/cli/selftune/localdb/queries/staging.ts +270 -0
  70. package/cli/selftune/localdb/queries/trust.ts +392 -0
  71. package/cli/selftune/localdb/queries.ts +60 -2162
  72. package/cli/selftune/localdb/schema.ts +59 -0
  73. package/cli/selftune/monitoring/watch.ts +96 -29
  74. package/cli/selftune/normalization.ts +3 -0
  75. package/cli/selftune/observability.ts +12 -3
  76. package/cli/selftune/orchestrate/cli.ts +161 -0
  77. package/cli/selftune/orchestrate/execute.ts +295 -0
  78. package/cli/selftune/orchestrate/finalize.ts +157 -0
  79. package/cli/selftune/orchestrate/locks.ts +40 -0
  80. package/cli/selftune/orchestrate/plan.ts +131 -0
  81. package/cli/selftune/orchestrate/post-run.ts +59 -0
  82. package/cli/selftune/orchestrate/prepare.ts +334 -0
  83. package/cli/selftune/orchestrate/report.ts +182 -0
  84. package/cli/selftune/orchestrate/runtime.ts +120 -0
  85. package/cli/selftune/orchestrate/signals.ts +48 -0
  86. package/cli/selftune/orchestrate.ts +162 -1142
  87. package/cli/selftune/registry/client.ts +74 -0
  88. package/cli/selftune/registry/history.ts +54 -0
  89. package/cli/selftune/registry/index.ts +90 -0
  90. package/cli/selftune/registry/install.ts +141 -0
  91. package/cli/selftune/registry/list.ts +44 -0
  92. package/cli/selftune/registry/push.ts +171 -0
  93. package/cli/selftune/registry/rollback.ts +49 -0
  94. package/cli/selftune/registry/status.ts +62 -0
  95. package/cli/selftune/registry/sync.ts +125 -0
  96. package/cli/selftune/repair/skill-usage.ts +9 -3
  97. package/cli/selftune/routes/overview.ts +5 -2
  98. package/cli/selftune/routes/skill-report.ts +15 -2
  99. package/cli/selftune/schedule.ts +5 -5
  100. package/cli/selftune/status.ts +70 -2
  101. package/cli/selftune/sync.ts +127 -23
  102. package/cli/selftune/testing-readiness.ts +597 -0
  103. package/cli/selftune/types.ts +46 -5
  104. package/cli/selftune/uninstall.ts +2 -1
  105. package/cli/selftune/utils/canonical-log.ts +1 -9
  106. package/cli/selftune/utils/cli-error.ts +9 -0
  107. package/cli/selftune/utils/jsonl.ts +1 -30
  108. package/cli/selftune/utils/llm-call.ts +126 -6
  109. package/cli/selftune/utils/skill-discovery.ts +24 -0
  110. package/cli/selftune/workflows/proposals.ts +184 -0
  111. package/cli/selftune/workflows/skill-scaffold.ts +241 -0
  112. package/cli/selftune/workflows/workflows.ts +100 -26
  113. package/node_modules/@selftune/telemetry-contract/fixtures/complete-push.ts +1 -1
  114. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +2 -2
  115. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  116. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
  117. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +2 -2
  118. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  119. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  120. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +63 -5
  121. package/node_modules/@selftune/telemetry-contract/src/types.ts +97 -7
  122. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  123. package/package.json +25 -9
  124. package/packages/dashboard-core/AGENTS.md +18 -0
  125. package/packages/dashboard-core/README.md +30 -0
  126. package/packages/dashboard-core/index.ts +3 -0
  127. package/packages/dashboard-core/package.json +39 -0
  128. package/packages/dashboard-core/src/chrome/DashboardChrome.tsx +74 -0
  129. package/packages/dashboard-core/src/chrome/DashboardHeader.tsx +200 -0
  130. package/packages/dashboard-core/src/chrome/DashboardSidebar.tsx +219 -0
  131. package/packages/dashboard-core/src/chrome/RuntimeBadge.tsx +46 -0
  132. package/packages/dashboard-core/src/chrome/index.ts +14 -0
  133. package/packages/dashboard-core/src/chrome/types.ts +81 -0
  134. package/packages/dashboard-core/src/chrome/utils.ts +23 -0
  135. package/packages/dashboard-core/src/gates/FeatureGate.tsx +11 -0
  136. package/packages/dashboard-core/src/gates/LockedRoute.tsx +29 -0
  137. package/packages/dashboard-core/src/gates/UpgradeCard.tsx +89 -0
  138. package/packages/dashboard-core/src/gates/index.ts +3 -0
  139. package/packages/dashboard-core/src/host/DashboardHostProvider.tsx +62 -0
  140. package/packages/dashboard-core/src/host/adapter.ts +47 -0
  141. package/packages/dashboard-core/src/host/capabilities.ts +55 -0
  142. package/packages/dashboard-core/src/host/index.ts +3 -0
  143. package/packages/dashboard-core/src/models/analytics.ts +39 -0
  144. package/packages/dashboard-core/src/models/index.ts +4 -0
  145. package/packages/dashboard-core/src/models/overview.ts +98 -0
  146. package/packages/dashboard-core/src/models/runtime.ts +7 -0
  147. package/packages/dashboard-core/src/models/skills.ts +34 -0
  148. package/packages/dashboard-core/src/routes/index.ts +2 -0
  149. package/packages/dashboard-core/src/routes/manifest.test.ts +70 -0
  150. package/packages/dashboard-core/src/routes/manifest.ts +451 -0
  151. package/packages/dashboard-core/src/routes/types.ts +39 -0
  152. package/packages/dashboard-core/src/screens/analytics/AnalyticsScreen.tsx +278 -0
  153. package/packages/dashboard-core/src/screens/analytics/index.ts +1 -0
  154. package/packages/dashboard-core/src/screens/index.ts +37 -0
  155. package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.test.ts +101 -0
  156. package/packages/dashboard-core/src/screens/overview/OverviewComparisonSurface.tsx +393 -0
  157. package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.test.tsx +113 -0
  158. package/packages/dashboard-core/src/screens/overview/OverviewCompositionSurface.tsx +72 -0
  159. package/packages/dashboard-core/src/screens/overview/OverviewCoreSurface.tsx +71 -0
  160. package/packages/dashboard-core/src/screens/overview/OverviewOnboardingBanner.tsx +90 -0
  161. package/packages/dashboard-core/src/screens/overview/OverviewRunSummary.tsx +40 -0
  162. package/packages/dashboard-core/src/screens/overview/index.ts +16 -0
  163. package/packages/dashboard-core/src/screens/overview/types.ts +13 -0
  164. package/packages/dashboard-core/src/screens/skill-report/SkillReportDailyBreakdownSection.tsx +99 -0
  165. package/packages/dashboard-core/src/screens/skill-report/SkillReportDataQualityTabContent.tsx +35 -0
  166. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceRail.tsx +71 -0
  167. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceSection.tsx +63 -0
  168. package/packages/dashboard-core/src/screens/skill-report/SkillReportEvidenceTabContent.tsx +25 -0
  169. package/packages/dashboard-core/src/screens/skill-report/SkillReportInvocationsSection.tsx +24 -0
  170. package/packages/dashboard-core/src/screens/skill-report/SkillReportMissedQueriesSection.tsx +79 -0
  171. package/packages/dashboard-core/src/screens/skill-report/SkillReportScaffold.tsx +150 -0
  172. package/packages/dashboard-core/src/screens/skill-report/SkillReportSections.test.tsx +224 -0
  173. package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.test.tsx +76 -0
  174. package/packages/dashboard-core/src/screens/skill-report/SkillReportTabs.tsx +88 -0
  175. package/packages/dashboard-core/src/screens/skill-report/SkillReportTrendSection.tsx +33 -0
  176. package/packages/dashboard-core/src/screens/skill-report/SkillReportTrustBadge.tsx +67 -0
  177. package/packages/dashboard-core/src/screens/skill-report/index.ts +45 -0
  178. package/packages/dashboard-core/src/screens/skills/SkillsLibraryScreen.tsx +162 -0
  179. package/packages/dashboard-core/src/screens/skills/index.ts +6 -0
  180. package/packages/telemetry-contract/fixtures/complete-push.ts +1 -1
  181. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +2 -2
  182. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  183. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +1 -1
  184. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +2 -2
  185. package/packages/telemetry-contract/package.json +1 -1
  186. package/packages/telemetry-contract/src/index.ts +1 -0
  187. package/packages/telemetry-contract/src/schemas.ts +63 -5
  188. package/packages/telemetry-contract/src/types.ts +97 -7
  189. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  190. package/packages/ui/AGENTS.md +16 -0
  191. package/packages/ui/README.md +1 -1
  192. package/packages/ui/package.json +1 -1
  193. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  194. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  195. package/packages/ui/src/components/EvidenceViewer.tsx +229 -464
  196. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  197. package/packages/ui/src/components/InfoTip.tsx +1 -2
  198. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  199. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  200. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  201. package/packages/ui/src/components/OverviewPanels.tsx +693 -0
  202. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  203. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  204. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  205. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  206. package/packages/ui/src/components/index.ts +56 -1
  207. package/packages/ui/src/components/section-cards.tsx +18 -35
  208. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  209. package/packages/ui/src/lib/constants.tsx +0 -1
  210. package/packages/ui/src/primitives/card.tsx +1 -1
  211. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  212. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  213. package/packages/ui/src/primitives/select.tsx +2 -2
  214. package/packages/ui/src/primitives/tabs.tsx +7 -6
  215. package/packages/ui/src/types.ts +182 -4
  216. package/skill/SKILL.md +130 -318
  217. package/skill/agents/diagnosis-analyst.md +3 -3
  218. package/skill/agents/evolution-reviewer.md +3 -3
  219. package/skill/agents/integration-guide.md +3 -3
  220. package/skill/agents/pattern-analyst.md +2 -2
  221. package/skill/references/cli-quick-reference.md +89 -0
  222. package/skill/references/creator-playbook.md +131 -0
  223. package/skill/references/examples.md +48 -0
  224. package/skill/references/troubleshooting.md +47 -0
  225. package/skill/references/version-history.md +1 -1
  226. package/skill/selftune.contribute.json +11 -0
  227. package/skill/{Workflows → workflows}/Baseline.md +20 -1
  228. package/skill/{Workflows → workflows}/Contribute.md +23 -10
  229. package/skill/{Workflows → workflows}/Contributions.md +13 -5
  230. package/skill/workflows/CreateTestDeploy.md +170 -0
  231. package/skill/{Workflows → workflows}/CreatorContributions.md +18 -6
  232. package/skill/{Workflows → workflows}/Cron.md +1 -1
  233. package/skill/{Workflows → workflows}/Dashboard.md +20 -0
  234. package/skill/{Workflows → workflows}/Doctor.md +1 -1
  235. package/skill/{Workflows → workflows}/Evals.md +67 -2
  236. package/skill/{Workflows → workflows}/Evolve.md +119 -30
  237. package/skill/{Workflows → workflows}/EvolveBody.md +41 -1
  238. package/skill/{Workflows → workflows}/Grade.md +1 -1
  239. package/skill/{Workflows → workflows}/Ingest.md +60 -2
  240. package/skill/{Workflows → workflows}/Initialize.md +16 -9
  241. package/skill/{Workflows → workflows}/Orchestrate.md +13 -3
  242. package/skill/{Workflows → workflows}/PlatformHooks.md +19 -3
  243. package/skill/workflows/Registry.md +99 -0
  244. package/skill/{Workflows → workflows}/Schedule.md +3 -3
  245. package/skill/workflows/SignalsDashboard.md +87 -0
  246. package/skill/{Workflows → workflows}/Sync.md +3 -1
  247. package/skill/{Workflows → workflows}/UnitTest.md +19 -0
  248. package/skill/{Workflows → workflows}/Watch.md +42 -2
  249. package/skill/{Workflows → workflows}/Workflows.md +39 -2
  250. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  251. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  252. package/apps/local-dashboard/dist/assets/vendor-react-CKkiCskZ.js +0 -11
  253. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  254. package/cli/selftune/utils/html.ts +0 -27
  255. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
  256. /package/skill/{Workflows → workflows}/AlphaUpload.md +0 -0
  257. /package/skill/{Workflows → workflows}/AutoActivation.md +0 -0
  258. /package/skill/{Workflows → workflows}/Badge.md +0 -0
  259. /package/skill/{Workflows → workflows}/Composability.md +0 -0
  260. /package/skill/{Workflows → workflows}/EvolutionMemory.md +0 -0
  261. /package/skill/{Workflows → workflows}/ExportCanonical.md +0 -0
  262. /package/skill/{Workflows → workflows}/Hook.md +0 -0
  263. /package/skill/{Workflows → workflows}/ImportSkillsBench.md +0 -0
  264. /package/skill/{Workflows → workflows}/Quickstart.md +0 -0
  265. /package/skill/{Workflows → workflows}/Recover.md +0 -0
  266. /package/skill/{Workflows → workflows}/RepairSkillUsage.md +0 -0
  267. /package/skill/{Workflows → workflows}/Replay.md +0 -0
  268. /package/skill/{Workflows → workflows}/Rollback.md +0 -0
  269. /package/skill/{Workflows → workflows}/Telemetry.md +0 -0
  270. /package/skill/{Workflows → workflows}/Uninstall.md +0 -0
@@ -22,7 +22,7 @@ import type { Database } from "bun:sqlite";
22
22
  import { existsSync, readFileSync, unwatchFile, watchFile } from "node:fs";
23
23
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
24
24
 
25
- import type { BadgeFormat } from "./badge/badge-svg.js";
25
+ import type { BadgeFormat } from "./badge/badge-data.js";
26
26
  import { LOG_DIR, SELFTUNE_CONFIG_DIR } from "./constants.js";
27
27
  import type {
28
28
  HealthResponse,
@@ -52,12 +52,13 @@ import {
52
52
  } from "./routes/index.js";
53
53
  import type { StatusResult } from "./status.js";
54
54
  import { computeStatus } from "./status.js";
55
- import type { EvolutionEvidenceEntry } from "./types.js";
55
+ import type { EvolutionAuditEntry, EvolutionEvidenceEntry } from "./types.js";
56
56
 
57
57
  export interface DashboardServerOptions {
58
58
  port?: number;
59
59
  host?: string;
60
60
  spaDir?: string;
61
+ spaProxyUrl?: string;
61
62
  openBrowser?: boolean;
62
63
  runtimeMode?: HealthResponse["process_mode"];
63
64
  statusLoader?: () => StatusResult | Promise<StatusResult>;
@@ -67,13 +68,18 @@ export interface DashboardServerOptions {
67
68
  actionRunner?: ActionRunner;
68
69
  }
69
70
 
70
- /** Read selftune version from package.json once at startup */
71
- let selftuneVersion = "unknown";
72
- try {
73
- const pkgPath = join(import.meta.dir, "..", "..", "package.json");
74
- selftuneVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
75
- } catch {
76
- // fallback already set
71
+ interface DashboardSocketData {
72
+ upstreamUrl?: string;
73
+ }
74
+
75
+ /** Read selftune version from package.json (fresh on each call to pick up auto-updates). */
76
+ const VERSION_PKG_PATH = join(import.meta.dir, "..", "..", "package.json");
77
+ function getSelftuneVersion(): string {
78
+ try {
79
+ return JSON.parse(readFileSync(VERSION_PKG_PATH, "utf-8")).version;
80
+ } catch {
81
+ return "unknown";
82
+ }
77
83
  }
78
84
 
79
85
  /** Resolve short git SHA once at startup (cached). */
@@ -89,6 +95,10 @@ function getGitSha(): string {
89
95
  return cachedGitSha;
90
96
  }
91
97
 
98
+ function getSpaBuildId(): string {
99
+ return process.env.SELFTUNE_SPA_BUILD_ID || getSelftuneVersion();
100
+ }
101
+
92
102
  const WORKSPACE_ROOT = resolve(import.meta.dir, "..", "..");
93
103
 
94
104
  function findSpaDir(): string | null {
@@ -121,6 +131,47 @@ function allowedDashboardOrigins(hostname: string, port: number): Set<string> {
121
131
  return origins;
122
132
  }
123
133
 
134
+ function normalizeSpaProxyUrl(rawValue: string | undefined): URL | null {
135
+ if (!rawValue) return null;
136
+ try {
137
+ const url = new URL(rawValue);
138
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
139
+ return null;
140
+ }
141
+ return url;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function shouldProxySpaRequest(pathname: string): boolean {
148
+ return (
149
+ !pathname.startsWith("/api/") &&
150
+ !pathname.startsWith("/badge/") &&
151
+ !pathname.startsWith("/report/")
152
+ );
153
+ }
154
+
155
+ async function proxySpaRequest(req: Request, proxyBaseUrl: URL, url: URL): Promise<Response> {
156
+ const targetUrl = new URL(`${url.pathname}${url.search}`, proxyBaseUrl);
157
+ const headers = new Headers(req.headers);
158
+ headers.set("host", targetUrl.host);
159
+ const upstreamResponse = await fetch(targetUrl, {
160
+ method: req.method,
161
+ headers,
162
+ redirect: "manual",
163
+ });
164
+ const proxiedHeaders = new Headers(upstreamResponse.headers);
165
+ for (const [key, value] of Object.entries(corsHeaders())) {
166
+ proxiedHeaders.set(key, value);
167
+ }
168
+ return new Response(upstreamResponse.body, {
169
+ status: upstreamResponse.status,
170
+ statusText: upstreamResponse.statusText,
171
+ headers: proxiedHeaders,
172
+ });
173
+ }
174
+
124
175
  const MIME_TYPES: Record<string, string> = {
125
176
  ".html": "text/html; charset=utf-8",
126
177
  ".js": "application/javascript; charset=utf-8",
@@ -139,7 +190,7 @@ async function computeStatusFromDb(): Promise<StatusResult> {
139
190
  const telemetry = querySessionTelemetry(db);
140
191
  const skillRecords = querySkillUsageRecords(db);
141
192
  const queryRecords = queryQueryLog(db);
142
- const auditEntries = queryEvolutionAudit(db);
193
+ const auditEntries = queryEvolutionAudit(db) as EvolutionAuditEntry[];
143
194
  const doctorResult = await doctor();
144
195
  return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
145
196
  }
@@ -172,6 +223,7 @@ export async function startDashboardServer(
172
223
  const hostname = options?.host ?? "localhost";
173
224
  const openBrowser = options?.openBrowser ?? true;
174
225
  const runtimeMode = options?.runtimeMode ?? (import.meta.main ? "dev-server" : "test");
226
+ const spaProxyUrl = normalizeSpaProxyUrl(options?.spaProxyUrl ?? process.env.SPA_PROXY_URL);
175
227
  const getStatusResult = options?.statusLoader ?? computeStatusFromDb;
176
228
  const getEvidenceEntries = options?.evidenceLoader ?? readEvidenceTrail;
177
229
  const getOverviewResponse = options?.overviewLoader;
@@ -182,7 +234,14 @@ export async function startDashboardServer(
182
234
  const requestedSpaDir = options?.spaDir ?? findSpaDir();
183
235
  const spaDir =
184
236
  requestedSpaDir && existsSync(join(requestedSpaDir, "index.html")) ? requestedSpaDir : null;
185
- if (spaDir) {
237
+ const spaMode: NonNullable<HealthResponse["spa_mode"]> = spaProxyUrl
238
+ ? "proxy"
239
+ : spaDir
240
+ ? "dist"
241
+ : "missing";
242
+ if (spaProxyUrl) {
243
+ console.log(`SPA proxy enabled at ${spaProxyUrl.toString()}`);
244
+ } else if (spaDir) {
186
245
  console.log(`SPA found at ${spaDir}, serving as default dashboard`);
187
246
  } else {
188
247
  if (options?.spaDir) {
@@ -247,6 +306,7 @@ export async function startDashboardServer(
247
306
 
248
307
  let fsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
249
308
  const FS_DEBOUNCE_MS = 500;
309
+ const proxiedSpaSockets = new Map<unknown, WebSocket>();
250
310
 
251
311
  function onWALChange(): void {
252
312
  if (fsDebounceTimer) return;
@@ -298,10 +358,48 @@ export async function startDashboardServer(
298
358
  }
299
359
 
300
360
  // -- HTTP request handler ---------------------------------------------------
301
- const server = Bun.serve({
361
+ const server = Bun.serve<DashboardSocketData>({
302
362
  port,
303
363
  hostname,
304
364
  idleTimeout: 255,
365
+ websocket: {
366
+ open(ws) {
367
+ const upstreamUrl = ws.data?.upstreamUrl;
368
+ if (!upstreamUrl) {
369
+ ws.close(1011, "Missing upstream websocket target");
370
+ return;
371
+ }
372
+ const upstreamSocket = new WebSocket(upstreamUrl);
373
+ proxiedSpaSockets.set(ws, upstreamSocket);
374
+ upstreamSocket.onmessage = (event) => {
375
+ ws.send(event.data);
376
+ };
377
+ upstreamSocket.onclose = (event) => {
378
+ proxiedSpaSockets.delete(ws);
379
+ try {
380
+ ws.close(event.code || 1000, event.reason);
381
+ } catch {
382
+ ws.close();
383
+ }
384
+ };
385
+ upstreamSocket.onerror = () => {
386
+ proxiedSpaSockets.delete(ws);
387
+ ws.close(1011, "Upstream websocket error");
388
+ };
389
+ },
390
+ message(ws, message) {
391
+ const upstreamSocket = proxiedSpaSockets.get(ws);
392
+ if (!upstreamSocket || upstreamSocket.readyState !== WebSocket.OPEN) {
393
+ return;
394
+ }
395
+ upstreamSocket.send(message);
396
+ },
397
+ close(ws) {
398
+ const upstreamSocket = proxiedSpaSockets.get(ws);
399
+ proxiedSpaSockets.delete(ws);
400
+ upstreamSocket?.close();
401
+ },
402
+ },
305
403
  async fetch(req) {
306
404
  const url = new URL(req.url);
307
405
 
@@ -315,8 +413,12 @@ export async function startDashboardServer(
315
413
  const healthResponse: HealthResponse = {
316
414
  ok: true,
317
415
  service: "selftune-dashboard",
318
- version: selftuneVersion,
319
- spa: Boolean(spaDir),
416
+ version: getSelftuneVersion(),
417
+ pid: process.pid,
418
+ spa: Boolean(spaDir || spaProxyUrl),
419
+ spa_mode: spaMode,
420
+ spa_build_id: getSpaBuildId(),
421
+ spa_proxy_url: spaProxyUrl?.toString() ?? null,
320
422
  v2_data_available: Boolean(getOverviewResponse || db),
321
423
  workspace_root: WORKSPACE_ROOT,
322
424
  git_sha: getGitSha(),
@@ -331,6 +433,26 @@ export async function startDashboardServer(
331
433
  return Response.json(healthResponse, { headers: corsHeaders() });
332
434
  }
333
435
 
436
+ if (
437
+ spaProxyUrl &&
438
+ req.headers.get("upgrade")?.toLowerCase() === "websocket" &&
439
+ shouldProxySpaRequest(url.pathname)
440
+ ) {
441
+ const upstreamUrl = new URL(`${url.pathname}${url.search}`, spaProxyUrl);
442
+ upstreamUrl.protocol = spaProxyUrl.protocol === "https:" ? "wss:" : "ws:";
443
+ if (
444
+ server.upgrade(req, {
445
+ data: { upstreamUrl: upstreamUrl.toString() },
446
+ })
447
+ ) {
448
+ return undefined;
449
+ }
450
+ return new Response("WebSocket upgrade failed", {
451
+ status: 502,
452
+ headers: corsHeaders(),
453
+ });
454
+ }
455
+
334
456
  // ---- GET /api/v2/events ---- SSE stream for live updates
335
457
  if (url.pathname === "/api/v2/events" && req.method === "GET") {
336
458
  const stream = new ReadableStream({
@@ -357,6 +479,22 @@ export async function startDashboardServer(
357
479
  return withCors(await handleDoctor());
358
480
  }
359
481
 
482
+ // ---- SPA static assets ----
483
+ if (spaProxyUrl && req.method === "GET" && shouldProxySpaRequest(url.pathname)) {
484
+ try {
485
+ return await proxySpaRequest(req, spaProxyUrl, url);
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ return new Response(
489
+ `Dashboard SPA proxy unavailable at ${spaProxyUrl.toString()}: ${message}`,
490
+ {
491
+ status: 502,
492
+ headers: { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() },
493
+ },
494
+ );
495
+ }
496
+ }
497
+
360
498
  // ---- SPA static assets ----
361
499
  if (spaDir && req.method === "GET" && url.pathname.startsWith("/assets/")) {
362
500
  const filePath = resolve(spaDir, `.${url.pathname}`);
@@ -470,7 +608,7 @@ export async function startDashboardServer(
470
608
  );
471
609
  }
472
610
  refreshV2Data();
473
- return withCors(handleOverview(db, selftuneVersion, url.searchParams));
611
+ return withCors(handleOverview(db, getSelftuneVersion(), url.searchParams));
474
612
  }
475
613
 
476
614
  // ---- GET /api/v2/orchestrate-runs ----
@@ -544,7 +682,7 @@ export async function startDashboardServer(
544
682
  },
545
683
  });
546
684
 
547
- boundPort = server.port;
685
+ boundPort = server.port ?? port;
548
686
 
549
687
  if (openBrowser) {
550
688
  const url = `http://${hostname}:${boundPort}`;
@@ -575,6 +713,14 @@ export async function startDashboardServer(
575
713
  }
576
714
  }
577
715
  sseClients.clear();
716
+ for (const upstreamSocket of proxiedSpaSockets.values()) {
717
+ try {
718
+ upstreamSocket.close();
719
+ } catch {
720
+ /* already closed */
721
+ }
722
+ }
723
+ proxiedSpaSockets.clear();
578
724
  if (fsDebounceTimer) clearTimeout(fsDebounceTimer);
579
725
  closeSingleton();
580
726
  server.stop();
@@ -602,5 +748,10 @@ if (import.meta.main) {
602
748
  runtimeModeArg === "standalone" || runtimeModeArg === "dev-server" || runtimeModeArg === "test"
603
749
  ? runtimeModeArg
604
750
  : "dev-server";
605
- startDashboardServer({ port, openBrowser: false, runtimeMode });
751
+ startDashboardServer({
752
+ port,
753
+ openBrowser: false,
754
+ runtimeMode,
755
+ spaProxyUrl: process.env.SPA_PROXY_URL,
756
+ });
606
757
  }
@@ -4,11 +4,354 @@
4
4
  * Usage:
5
5
  * selftune dashboard — Start server on port 3141 and open browser
6
6
  * selftune dashboard --port 8080 — Start on custom port
7
+ * selftune dashboard --restart — Restart an existing dashboard on the target port
7
8
  * selftune dashboard --serve — Deprecated alias for the default behavior
8
9
  */
9
10
 
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import type { HealthResponse } from "./dashboard-contract.js";
10
15
  import { CLIError } from "./utils/cli-error.js";
11
16
 
17
+ const DEFAULT_PORT = 3141;
18
+ const VERSION_PKG_PATH = join(import.meta.dir, "..", "..", "package.json");
19
+ const HEALTHCHECK_TIMEOUT_MS = 1000;
20
+ const RESTART_WAIT_TIMEOUT_MS = 5000;
21
+ const RESTART_POLL_INTERVAL_MS = 250;
22
+
23
+ type DashboardServerHandle = Awaited<
24
+ ReturnType<typeof import("./dashboard-server.js").startDashboardServer>
25
+ >;
26
+ type DashboardStartOptions = Parameters<
27
+ typeof import("./dashboard-server.js").startDashboardServer
28
+ >[0];
29
+ type DashboardKillFn = (pid: number, signal?: string | number) => boolean;
30
+
31
+ type DashboardRuntimeHealth = Partial<HealthResponse> & {
32
+ ok: boolean;
33
+ service: string;
34
+ pid?: number;
35
+ };
36
+
37
+ interface DashboardLaunchOptions {
38
+ openBrowser: boolean;
39
+ port: number;
40
+ restart: boolean;
41
+ }
42
+
43
+ interface DashboardLaunchResult {
44
+ action: "reused" | "started";
45
+ installedVersion: string;
46
+ serverHandle?: DashboardServerHandle;
47
+ url: string;
48
+ }
49
+
50
+ interface DashboardLaunchDeps {
51
+ fetch?: typeof fetch;
52
+ findListeningPids?: (port: number) => number[];
53
+ kill?: DashboardKillFn;
54
+ log?: Pick<typeof console, "log" | "warn">;
55
+ openUrl?: (url: string) => void;
56
+ startDashboardServer?: (options?: DashboardStartOptions) => Promise<DashboardServerHandle>;
57
+ wait?: (ms: number) => Promise<void>;
58
+ }
59
+
60
+ function getInstalledSelftuneVersion(): string {
61
+ try {
62
+ return JSON.parse(readFileSync(VERSION_PKG_PATH, "utf-8")).version;
63
+ } catch {
64
+ return "unknown";
65
+ }
66
+ }
67
+
68
+ function buildDashboardUrl(port: number): string {
69
+ return `http://localhost:${port}`;
70
+ }
71
+
72
+ function openDashboardUrl(url: string): void {
73
+ try {
74
+ const platform = process.platform;
75
+ if (platform === "darwin") {
76
+ Bun.spawn(["open", url]);
77
+ } else if (platform === "linux") {
78
+ Bun.spawn(["xdg-open", url]);
79
+ } else if (platform === "win32") {
80
+ Bun.spawn(["cmd", "/c", "start", "", url]);
81
+ } else {
82
+ console.log(`Open manually: ${url}`);
83
+ }
84
+ } catch {
85
+ console.log(`Open manually: ${url}`);
86
+ }
87
+ }
88
+
89
+ function isAddressInUseError(error: unknown): boolean {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ return /EADDRINUSE|address already in use|port .* in use|already in use/i.test(message);
92
+ }
93
+
94
+ function parsePidOutput(output: string): number[] {
95
+ const pids = new Set<number>();
96
+ for (const line of output.split(/\r?\n/)) {
97
+ const trimmed = line.trim();
98
+ if (!trimmed) continue;
99
+ const pid = Number.parseInt(trimmed, 10);
100
+ if (Number.isInteger(pid) && pid > 0) {
101
+ pids.add(pid);
102
+ }
103
+ }
104
+ return [...pids];
105
+ }
106
+
107
+ export function parseWindowsNetstatListeningPids(output: string, port: number): number[] {
108
+ const pids = new Set<number>();
109
+ const portSuffix = `:${port}`;
110
+
111
+ for (const line of output.split(/\r?\n/)) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed || !trimmed.includes("LISTENING")) continue;
114
+
115
+ const parts = trimmed.split(/\s+/);
116
+ const localAddr = parts[1] ?? "";
117
+ if (!localAddr.endsWith(portSuffix)) continue;
118
+
119
+ const pid = Number.parseInt(parts.at(-1) ?? "", 10);
120
+ if (Number.isInteger(pid) && pid > 0) {
121
+ pids.add(pid);
122
+ }
123
+ }
124
+
125
+ return [...pids];
126
+ }
127
+
128
+ function findListeningPids(port: number): number[] {
129
+ if (process.platform === "win32") {
130
+ const result = Bun.spawnSync(["cmd", "/c", "netstat -ano -p tcp"], {
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+ if (result.exitCode !== 0) {
135
+ return [];
136
+ }
137
+ return parseWindowsNetstatListeningPids(result.stdout.toString(), port);
138
+ }
139
+
140
+ const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
141
+ stdout: "pipe",
142
+ stderr: "pipe",
143
+ });
144
+ if (result.exitCode !== 0) {
145
+ return [];
146
+ }
147
+ return parsePidOutput(result.stdout.toString());
148
+ }
149
+
150
+ async function probeDashboardHealth(
151
+ port: number,
152
+ fetchImpl: typeof fetch = globalThis.fetch,
153
+ ): Promise<DashboardRuntimeHealth | null> {
154
+ const controller = new AbortController();
155
+ const timeout = setTimeout(() => controller.abort(), HEALTHCHECK_TIMEOUT_MS);
156
+ try {
157
+ const response = await fetchImpl(`${buildDashboardUrl(port)}/api/health`, {
158
+ signal: controller.signal,
159
+ });
160
+ if (!response.ok) {
161
+ return null;
162
+ }
163
+ const payload = (await response.json()) as Partial<DashboardRuntimeHealth>;
164
+ if (payload.service !== "selftune-dashboard" || payload.ok !== true) {
165
+ return null;
166
+ }
167
+ return payload as DashboardRuntimeHealth;
168
+ } catch {
169
+ return null;
170
+ } finally {
171
+ clearTimeout(timeout);
172
+ }
173
+ }
174
+
175
+ async function waitForDashboardShutdown(port: number, deps: DashboardLaunchDeps): Promise<void> {
176
+ const wait =
177
+ deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
178
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
179
+ const deadline = Date.now() + RESTART_WAIT_TIMEOUT_MS;
180
+
181
+ while (Date.now() < deadline) {
182
+ const health = await probeDashboardHealth(port, fetchImpl);
183
+ if (!health) {
184
+ return;
185
+ }
186
+ await wait(RESTART_POLL_INTERVAL_MS);
187
+ }
188
+
189
+ throw new CLIError(
190
+ `Timed out waiting for the existing dashboard on port ${port} to stop.`,
191
+ "OPERATION_FAILED",
192
+ "Retry `selftune dashboard --restart` or stop the existing dashboard process manually.",
193
+ );
194
+ }
195
+
196
+ async function stopExistingDashboard(
197
+ port: number,
198
+ health: DashboardRuntimeHealth,
199
+ deps: DashboardLaunchDeps,
200
+ ): Promise<void> {
201
+ const listeningPids = deps.findListeningPids?.(port) ?? findListeningPids(port);
202
+ const pids = new Set<number>();
203
+
204
+ if (typeof health.pid === "number" && health.pid > 0) {
205
+ pids.add(health.pid);
206
+ }
207
+
208
+ for (const pid of listeningPids) {
209
+ if (pid > 0) {
210
+ pids.add(pid);
211
+ }
212
+ }
213
+
214
+ pids.delete(process.pid);
215
+
216
+ if (pids.size === 0) {
217
+ throw new CLIError(
218
+ `Found a running dashboard on port ${port}, but could not determine its process ID.`,
219
+ "OPERATION_FAILED",
220
+ `Stop the dashboard on port ${port} manually, then rerun \`selftune dashboard --port ${port}\`.`,
221
+ );
222
+ }
223
+
224
+ const kill = deps.kill ?? process.kill.bind(process);
225
+ for (const pid of pids) {
226
+ try {
227
+ kill(pid, "SIGTERM");
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ if (!/ESRCH|no such process/i.test(message)) {
231
+ throw new CLIError(
232
+ `Failed to stop dashboard process ${pid}: ${message}`,
233
+ "OPERATION_FAILED",
234
+ `Stop the process on port ${port} manually, then rerun \`selftune dashboard --port ${port}\`.`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+
240
+ await waitForDashboardShutdown(port, deps);
241
+ }
242
+
243
+ export function parseDashboardOptions(
244
+ args: string[] = process.argv.slice(2),
245
+ ): DashboardLaunchOptions {
246
+ const portIdx = args.indexOf("--port");
247
+ let port = DEFAULT_PORT;
248
+
249
+ if (portIdx !== -1) {
250
+ const parsed = Number.parseInt(args[portIdx + 1], 10);
251
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
252
+ throw new CLIError(
253
+ `Invalid port "${args[portIdx + 1]}": must be an integer between 1 and 65535.`,
254
+ "INVALID_FLAG",
255
+ "Provide a port number between 1 and 65535 (e.g., --port 3141).",
256
+ );
257
+ }
258
+ port = parsed;
259
+ }
260
+
261
+ return {
262
+ openBrowser: !args.includes("--no-open"),
263
+ port,
264
+ restart: args.includes("--restart"),
265
+ };
266
+ }
267
+
268
+ export async function launchDashboard(
269
+ args: string[] = process.argv.slice(2),
270
+ deps: DashboardLaunchDeps = {},
271
+ ): Promise<DashboardLaunchResult> {
272
+ const options = parseDashboardOptions(args);
273
+ const log = deps.log ?? console;
274
+ const openUrl = deps.openUrl ?? openDashboardUrl;
275
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
276
+ const installedVersion = getInstalledSelftuneVersion();
277
+ const url = buildDashboardUrl(options.port);
278
+
279
+ const runningDashboard = await probeDashboardHealth(options.port, fetchImpl);
280
+ const versionMismatch =
281
+ runningDashboard?.process_mode === "standalone" &&
282
+ runningDashboard.version !== undefined &&
283
+ runningDashboard.version !== "unknown" &&
284
+ installedVersion !== "unknown" &&
285
+ runningDashboard.version !== installedVersion;
286
+
287
+ if (runningDashboard) {
288
+ if (options.restart || versionMismatch) {
289
+ if (versionMismatch) {
290
+ log.log(
291
+ `Installed selftune ${installedVersion} differs from running dashboard ${runningDashboard.version}. Restarting ${url} to pick up the update.`,
292
+ );
293
+ } else {
294
+ log.log(`Restarting existing selftune dashboard at ${url}.`);
295
+ }
296
+ await stopExistingDashboard(options.port, runningDashboard, deps);
297
+ } else {
298
+ if (
299
+ runningDashboard.process_mode !== "standalone" &&
300
+ runningDashboard.version !== installedVersion &&
301
+ installedVersion !== "unknown"
302
+ ) {
303
+ log.warn(
304
+ `Dashboard already running at ${url} from ${runningDashboard.process_mode} mode (version ${runningDashboard.version}). Reusing it without restart.`,
305
+ );
306
+ } else {
307
+ log.log(`Reusing existing selftune dashboard at ${url}.`);
308
+ }
309
+ if (options.openBrowser) {
310
+ openUrl(url);
311
+ }
312
+ return { action: "reused", installedVersion, url };
313
+ }
314
+ }
315
+
316
+ const startDashboardServer =
317
+ deps.startDashboardServer ?? (await import("./dashboard-server.js")).startDashboardServer;
318
+
319
+ try {
320
+ const serverHandle = await startDashboardServer({
321
+ port: options.port,
322
+ openBrowser: options.openBrowser,
323
+ runtimeMode: "standalone",
324
+ });
325
+ return {
326
+ action: "started",
327
+ installedVersion,
328
+ serverHandle,
329
+ url: buildDashboardUrl(serverHandle.port),
330
+ };
331
+ } catch (error) {
332
+ const liveDashboard = await probeDashboardHealth(options.port, fetchImpl);
333
+ if (liveDashboard && !options.restart) {
334
+ log.log(`Reusing existing selftune dashboard at ${url}.`);
335
+ if (options.openBrowser) {
336
+ openUrl(url);
337
+ }
338
+ return { action: "reused", installedVersion, url };
339
+ }
340
+
341
+ if (isAddressInUseError(error)) {
342
+ throw new CLIError(
343
+ `Port ${options.port} is already in use.`,
344
+ "OPERATION_FAILED",
345
+ liveDashboard
346
+ ? `Run \`selftune dashboard --port ${options.port} --restart\` to replace the existing dashboard.`
347
+ : `Use \`selftune dashboard --port <port>\` or stop the process currently listening on ${options.port}.`,
348
+ );
349
+ }
350
+
351
+ throw error;
352
+ }
353
+ }
354
+
12
355
  export async function cliMain(): Promise<void> {
13
356
  const args = process.argv.slice(2);
14
357
 
@@ -18,6 +361,7 @@ export async function cliMain(): Promise<void> {
18
361
  Usage:
19
362
  selftune dashboard Start dashboard server (port 3141)
20
363
  selftune dashboard --port 8080 Start on custom port
364
+ selftune dashboard --restart Restart existing dashboard on the target port
21
365
  selftune dashboard --serve Deprecated alias for default behavior
22
366
  selftune dashboard --no-open Start server without opening browser`);
23
367
  process.exit(0);
@@ -31,27 +375,16 @@ Usage:
31
375
  );
32
376
  }
33
377
 
34
- const portIdx = args.indexOf("--port");
35
- let port: number | undefined;
36
- if (portIdx !== -1) {
37
- const parsed = Number.parseInt(args[portIdx + 1], 10);
38
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
39
- throw new CLIError(
40
- `Invalid port "${args[portIdx + 1]}": must be an integer between 1 and 65535.`,
41
- "INVALID_FLAG",
42
- "Provide a port number between 1 and 65535 (e.g., --port 3141).",
43
- );
44
- }
45
- port = parsed;
46
- }
47
-
48
378
  if (args.includes("--serve")) {
49
379
  console.warn("`selftune dashboard --serve` is deprecated; use `selftune dashboard` instead.");
50
380
  }
51
381
 
52
- const openBrowser = !args.includes("--no-open");
53
- const { startDashboardServer } = await import("./dashboard-server.js");
54
- const { stop } = await startDashboardServer({ port, openBrowser, runtimeMode: "standalone" });
382
+ const launch = await launchDashboard(args);
383
+ if (launch.action === "reused" || !launch.serverHandle) {
384
+ return;
385
+ }
386
+
387
+ const { stop } = launch.serverHandle;
55
388
  await new Promise<void>((resolve) => {
56
389
  let closed = false;
57
390
  const keepAlive = setInterval(() => {}, 1 << 30);