@oh-my-pi/omp-stats 17.0.4 → 17.0.6

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.
@@ -1,10 +1,11 @@
1
- /*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
2
  @layer properties;
3
3
  @layer theme, base, components, utilities;
4
4
  @layer theme {
5
5
  :root, :host {
6
- --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
7
- "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
6
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
7
+ "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
8
+ "Segoe UI Symbol", "Noto Color Emoji";
8
9
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
10
  "Courier New", monospace;
10
11
  --spacing: 0.25rem;
@@ -40,7 +41,7 @@
40
41
  line-height: 1.5;
41
42
  -webkit-text-size-adjust: 100%;
42
43
  tab-size: 4;
43
- font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
44
+ font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
44
45
  font-feature-settings: var(--default-font-feature-settings, normal);
45
46
  font-variation-settings: var(--default-font-variation-settings, normal);
46
47
  -webkit-tap-highlight-color: transparent;
@@ -92,7 +93,7 @@
92
93
  border-color: inherit;
93
94
  border-collapse: collapse;
94
95
  }
95
- :-moz-focusring {
96
+ :-moz-focusring:where(:not(iframe)) {
96
97
  outline: auto;
97
98
  }
98
99
  progress {
@@ -251,7 +252,7 @@
251
252
  max-width: 440px;
252
253
  }
253
254
  .min-w-0 {
254
- min-width: 0;
255
+ min-width: 0px;
255
256
  }
256
257
  .flex-1 {
257
258
  flex: 1;
@@ -298,40 +299,30 @@
298
299
  .gap-6 {
299
300
  gap: calc(var(--spacing) * 6);
300
301
  }
301
- .space-y-0\.5 {
302
- :where(& > :not(:last-child)) {
303
- --tw-space-y-reverse: 0;
304
- margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse));
305
- margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse)));
306
- }
302
+ :where(.space-y-0\.5 > :not(:last-child)) {
303
+ --tw-space-y-reverse: 0;
304
+ margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse));
305
+ margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse)));
307
306
  }
308
- .space-y-1 {
309
- :where(& > :not(:last-child)) {
310
- --tw-space-y-reverse: 0;
311
- margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse));
312
- margin-block-end: calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)));
313
- }
307
+ :where(.space-y-1 > :not(:last-child)) {
308
+ --tw-space-y-reverse: 0;
309
+ margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse));
310
+ margin-block-end: calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)));
314
311
  }
315
- .space-y-2 {
316
- :where(& > :not(:last-child)) {
317
- --tw-space-y-reverse: 0;
318
- margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
319
- margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
320
- }
312
+ :where(.space-y-2 > :not(:last-child)) {
313
+ --tw-space-y-reverse: 0;
314
+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
315
+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
321
316
  }
322
- .space-y-4 {
323
- :where(& > :not(:last-child)) {
324
- --tw-space-y-reverse: 0;
325
- margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
326
- margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
327
- }
317
+ :where(.space-y-4 > :not(:last-child)) {
318
+ --tw-space-y-reverse: 0;
319
+ margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
320
+ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
328
321
  }
329
- .space-y-6 {
330
- :where(& > :not(:last-child)) {
331
- --tw-space-y-reverse: 0;
332
- margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
333
- margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
334
- }
322
+ :where(.space-y-6 > :not(:last-child)) {
323
+ --tw-space-y-reverse: 0;
324
+ margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
325
+ margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
335
326
  }
336
327
  .truncate {
337
328
  overflow: hidden;
@@ -466,30 +457,24 @@
466
457
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
467
458
  transition-duration: var(--tw-duration, var(--default-transition-duration));
468
459
  }
469
- .hover\:bg-\[var\(--bg-hover\)\] {
470
- &:hover {
471
- @media (hover: hover) {
472
- background-color: var(--bg-hover);
473
- }
460
+ @media (hover: hover) {
461
+ .hover\:bg-\[var\(--bg-hover\)\]:hover {
462
+ background-color: var(--bg-hover);
474
463
  }
475
464
  }
476
- .md\:grid-cols-3 {
477
- @media (width >= 48rem) {
465
+ @media (width >= 48rem) {
466
+ .md\:grid-cols-3 {
478
467
  grid-template-columns: repeat(3, minmax(0, 1fr));
479
468
  }
480
- }
481
- .md\:grid-cols-6 {
482
- @media (width >= 48rem) {
469
+ .md\:grid-cols-6 {
483
470
  grid-template-columns: repeat(6, minmax(0, 1fr));
484
471
  }
485
472
  }
486
- .lg\:col-span-2 {
487
- @media (width >= 64rem) {
473
+ @media (width >= 64rem) {
474
+ .lg\:col-span-2 {
488
475
  grid-column: span 2 / span 2;
489
476
  }
490
- }
491
- .lg\:grid-cols-3 {
492
- @media (width >= 64rem) {
477
+ .lg\:grid-cols-3 {
493
478
  grid-template-columns: repeat(3, minmax(0, 1fr));
494
479
  }
495
480
  }
@@ -974,15 +959,15 @@
974
959
  }
975
960
  .stats-metric-secondary-grid {
976
961
  display: grid;
977
- grid-template-columns: repeat(6, 1fr);
962
+ grid-template-columns: repeat(8, 1fr);
978
963
  gap: 12px;
979
964
  }
980
- @media (max-width: 1023px) {
965
+ @media (max-width: 1279px) {
981
966
  .stats-metric-secondary-grid {
982
- grid-template-columns: repeat(3, 1fr);
967
+ grid-template-columns: repeat(4, 1fr);
983
968
  }
984
969
  }
985
- @media (max-width: 600px) {
970
+ @media (max-width: 767px) {
986
971
  .stats-metric-secondary-grid {
987
972
  grid-template-columns: repeat(2, 1fr);
988
973
  }
@@ -1,4 +1,12 @@
1
1
  import type { AgentType, AgentTypeStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, CostTimeSeriesPoint, FolderStats, ModelPerformancePoint, TimeRange, ToolUsageStats } from "../types.js";
2
+ export interface ConversationTokenStats {
3
+ totalInputTokens: number;
4
+ totalOutputTokens: number;
5
+ totalCacheReadTokens: number;
6
+ totalCacheWriteTokens: number;
7
+ }
8
+ /** Sum every conversation-token bucket shown by the overview. */
9
+ export declare function sumConversationTokens(stats: ConversationTokenStats): number;
2
10
  export interface AgentTokenSegment {
3
11
  agentType: AgentType;
4
12
  /** input + output + cache read + cache write — the displayed denominator. */
@@ -16,9 +24,8 @@ export interface AgentTokenShareView {
16
24
  /**
17
25
  * Build the "token usage by agent" breakdown: one segment per agent type that
18
26
  * appears in the data, ordered main -> subagents -> advisor, each carrying its
19
- * token total and share of the grand total. Token counts sum the same four
20
- * columns the overview renders (input + output + cache read + cache write) so a
21
- * segment's share never disagrees with the count beside it.
27
+ * token total and share of the grand total. Token counts use the same four
28
+ * conversation-token buckets as the overview total so the two views reconcile.
22
29
  */
23
30
  export declare function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareView;
24
31
  export interface CostSummaryView {
@@ -0,0 +1,4 @@
1
+ /** Header stamped on every dashboard response so reuse probes can identify us. */
2
+ export declare const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
3
+ /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
4
+ export declare function recoverStatsPort(port: number): Promise<"retry" | "reuse">;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export declare function handleApi(req: Request): Promise<Response>;
5
5
  /**
6
- * Start the HTTP server.
6
+ * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
7
7
  */
8
8
  export declare function startServer(port?: number): Promise<{
9
9
  port: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "17.0.4",
4
+ "version": "17.0.6",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "17.0.4",
43
- "@oh-my-pi/pi-catalog": "17.0.4",
44
- "@oh-my-pi/pi-utils": "17.0.4",
42
+ "@oh-my-pi/pi-ai": "17.0.6",
43
+ "@oh-my-pi/pi-catalog": "17.0.6",
44
+ "@oh-my-pi/pi-utils": "17.0.6",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
@@ -14,6 +14,18 @@ import type {
14
14
  /** Fixed display order for the agent-token-share breakdown. */
15
15
  const AGENT_TYPE_ORDER: AgentType[] = ["main", "subagent", "advisor"];
16
16
 
17
+ export interface ConversationTokenStats {
18
+ totalInputTokens: number;
19
+ totalOutputTokens: number;
20
+ totalCacheReadTokens: number;
21
+ totalCacheWriteTokens: number;
22
+ }
23
+
24
+ /** Sum every conversation-token bucket shown by the overview. */
25
+ export function sumConversationTokens(stats: ConversationTokenStats): number {
26
+ return stats.totalInputTokens + stats.totalOutputTokens + stats.totalCacheReadTokens + stats.totalCacheWriteTokens;
27
+ }
28
+
17
29
  export interface AgentTokenSegment {
18
30
  agentType: AgentType;
19
31
  /** input + output + cache read + cache write — the displayed denominator. */
@@ -33,25 +45,21 @@ export interface AgentTokenShareView {
33
45
  /**
34
46
  * Build the "token usage by agent" breakdown: one segment per agent type that
35
47
  * appears in the data, ordered main -> subagents -> advisor, each carrying its
36
- * token total and share of the grand total. Token counts sum the same four
37
- * columns the overview renders (input + output + cache read + cache write) so a
38
- * segment's share never disagrees with the count beside it.
48
+ * token total and share of the grand total. Token counts use the same four
49
+ * conversation-token buckets as the overview total so the two views reconcile.
39
50
  */
40
51
  export function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareView {
41
52
  const byType = new Map<AgentType, AgentTypeStats>();
42
53
  for (const stat of stats) byType.set(stat.agentType, stat);
43
54
 
44
- const tokensOf = (stat: AgentTypeStats) =>
45
- stat.totalInputTokens + stat.totalOutputTokens + stat.totalCacheReadTokens + stat.totalCacheWriteTokens;
46
-
47
55
  const present = AGENT_TYPE_ORDER.map(type => byType.get(type)).filter(
48
56
  (stat): stat is AgentTypeStats => stat !== undefined,
49
57
  );
50
- const totalTokens = present.reduce((sum, stat) => sum + tokensOf(stat), 0);
58
+ const totalTokens = present.reduce((sum, stat) => sum + sumConversationTokens(stat), 0);
51
59
  const totalCost = present.reduce((sum, stat) => sum + stat.totalCost, 0);
52
60
 
53
61
  const segments = present.map(stat => {
54
- const tokens = tokensOf(stat);
62
+ const tokens = sumConversationTokens(stat);
55
63
  return {
56
64
  agentType: stat.agentType,
57
65
  tokens,
@@ -226,8 +226,8 @@ export function OverviewRoute({ active, range, refreshTrigger, onRequestClick }:
226
226
  </AsyncBoundary>
227
227
 
228
228
  <Panel
229
- title="Token Usage by Agent"
230
- subtitle="Share of tokens across the main agent, task subagents, and the advisor"
229
+ title="Conversation Tokens by Agent"
230
+ subtitle="Uncached input + cache reads + cache writes + output, grouped by agent type"
231
231
  >
232
232
  <AsyncBoundary loading={overviewLoading} error={overviewError} data={overview}>
233
233
  {overview && <AgentTokenShare stats={overview.byAgentType} />}
@@ -581,17 +581,17 @@
581
581
 
582
582
  .stats-metric-secondary-grid {
583
583
  display: grid;
584
- grid-template-columns: repeat(6, 1fr);
584
+ grid-template-columns: repeat(8, 1fr);
585
585
  gap: 12px;
586
586
  }
587
587
 
588
- @media (max-width: 1023px) {
588
+ @media (max-width: 1279px) {
589
589
  .stats-metric-secondary-grid {
590
- grid-template-columns: repeat(3, 1fr);
590
+ grid-template-columns: repeat(4, 1fr);
591
591
  }
592
592
  }
593
593
 
594
- @media (max-width: 600px) {
594
+ @media (max-width: 767px) {
595
595
  .stats-metric-secondary-grid {
596
596
  grid-template-columns: repeat(2, 1fr);
597
597
  }
@@ -6,6 +6,7 @@ import {
6
6
  formatPercent,
7
7
  formatTokensPerSecond,
8
8
  } from "../data/formatters";
9
+ import { sumConversationTokens } from "../data/view-models";
9
10
  import type { AggregatedStats } from "../types";
10
11
 
11
12
  export interface MetricClusterProps {
@@ -13,6 +14,8 @@ export interface MetricClusterProps {
13
14
  }
14
15
 
15
16
  export function MetricCluster({ stats }: MetricClusterProps) {
17
+ const conversationTokens = sumConversationTokens(stats);
18
+
16
19
  return (
17
20
  <div className="stats-metric-cluster">
18
21
  <div className="stats-metric-primary-grid">
@@ -37,14 +40,22 @@ export function MetricCluster({ stats }: MetricClusterProps) {
37
40
  </div>
38
41
 
39
42
  <div className="stats-metric-secondary-grid">
40
- <div className="stats-metric-card secondary">
41
- <div className="stats-metric-label">Input Tokens</div>
43
+ <div className="stats-metric-card secondary" title="Conversation input not served from cache">
44
+ <div className="stats-metric-label">Uncached Input</div>
42
45
  <div className="stats-metric-value">{formatCompact(stats.totalInputTokens)}</div>
43
46
  </div>
47
+ <div className="stats-metric-card secondary" title="Conversation input read from the prompt cache">
48
+ <div className="stats-metric-label">Cache Read</div>
49
+ <div className="stats-metric-value">{formatCompact(stats.totalCacheReadTokens)}</div>
50
+ </div>
44
51
  <div className="stats-metric-card secondary">
45
52
  <div className="stats-metric-label">Output Tokens</div>
46
53
  <div className="stats-metric-value">{formatCompact(stats.totalOutputTokens)}</div>
47
54
  </div>
55
+ <div className="stats-metric-card secondary" title="Uncached input + cache reads + cache writes + output">
56
+ <div className="stats-metric-label">Conversation Total</div>
57
+ <div className="stats-metric-value">{formatCompact(conversationTokens)}</div>
58
+ </div>
48
59
  <div className="stats-metric-card secondary">
49
60
  <div className="stats-metric-label">Premium Requests</div>
50
61
  <div className="stats-metric-value">{formatInteger(stats.totalPremiumRequests)}</div>
@@ -0,0 +1,250 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { $which } from "@oh-my-pi/pi-utils";
5
+ import { $ } from "bun";
6
+
7
+ const STATS_PROBE_TIMEOUT_MS = 500;
8
+ const PROCESS_EXIT_POLL_MS = 50;
9
+ const PROCESS_EXIT_POLLS = 10;
10
+ const STATS_RUNTIME_IMAGES: Record<string, true> = { bun: true, node: true, omp: true, "omp-stats": true };
11
+
12
+ interface PortHolder {
13
+ pid: number;
14
+ image: string;
15
+ commandLine: string;
16
+ }
17
+
18
+ /** Header stamped on every dashboard response so reuse probes can identify us. */
19
+ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
20
+
21
+ async function probeStatsDashboard(port: number): Promise<boolean> {
22
+ try {
23
+ const response = await fetch(`http://localhost:${port}/api/stats/models`, {
24
+ signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
25
+ });
26
+ if (response.status !== 200) {
27
+ await response.body?.cancel();
28
+ return false;
29
+ }
30
+ // A live omp-stats dashboard stamps this header on every response.
31
+ if (response.headers.get(STATS_DASHBOARD_HEADER)) {
32
+ await response.body?.cancel();
33
+ return true;
34
+ }
35
+ // Older dashboards predate the header; fall back to the response shape
36
+ // (`/api/stats/models` returns a JSON array) so we never reuse — or later
37
+ // kill — a foreign 200 responder such as an SPA dev server catch-all.
38
+ if (!(response.headers.get("content-type") ?? "").includes("application/json")) {
39
+ await response.body?.cancel();
40
+ return false;
41
+ }
42
+ return Array.isArray(await response.json());
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ async function findLinuxPortHolder(port: number): Promise<PortHolder | null> {
49
+ const socketInodes = new Set<string>();
50
+ for (const tablePath of ["/proc/net/tcp", "/proc/net/tcp6"]) {
51
+ let table: string;
52
+ try {
53
+ table = await Bun.file(tablePath).text();
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ for (const line of table.split("\n").slice(1)) {
59
+ const fields = line.trim().split(/\s+/);
60
+ const localAddress = fields[1];
61
+ const state = fields[3];
62
+ const inode = fields[9];
63
+ if (!localAddress || state !== "0A" || !inode) continue;
64
+ const encodedPort = localAddress.slice(localAddress.lastIndexOf(":") + 1);
65
+ if (Number.parseInt(encodedPort, 16) === port) socketInodes.add(inode);
66
+ }
67
+ }
68
+ if (socketInodes.size === 0) return null;
69
+
70
+ let processes: Dirent[];
71
+ try {
72
+ processes = await fs.readdir("/proc", { withFileTypes: true });
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ for (const entry of processes) {
78
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
79
+ const pid = Number.parseInt(entry.name, 10);
80
+ let descriptors: string[];
81
+ try {
82
+ descriptors = await fs.readdir(`/proc/${pid}/fd`);
83
+ } catch {
84
+ continue;
85
+ }
86
+
87
+ let ownsSocket = false;
88
+ for (const descriptor of descriptors) {
89
+ try {
90
+ const target = await fs.readlink(`/proc/${pid}/fd/${descriptor}`);
91
+ const match = /^socket:\[(\d+)]$/.exec(target);
92
+ if (match?.[1] && socketInodes.has(match[1])) {
93
+ ownsSocket = true;
94
+ break;
95
+ }
96
+ } catch {}
97
+ }
98
+ if (!ownsSocket) continue;
99
+
100
+ let commandLine = "";
101
+ try {
102
+ const rawCommandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
103
+ commandLine = rawCommandLine.split("\0").filter(Boolean).join(" ");
104
+ } catch {}
105
+
106
+ try {
107
+ const executable = await fs.readlink(`/proc/${pid}/exe`);
108
+ return { pid, image: path.basename(executable), commandLine };
109
+ } catch {
110
+ const executable = commandLine.split(" ", 1)[0];
111
+ return { pid, image: executable ? path.basename(executable) : "unknown", commandLine };
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ async function findMacPortHolder(port: number): Promise<PortHolder | null> {
118
+ const lsof = $which("lsof") ?? ((await Bun.file("/usr/sbin/lsof").exists()) ? "/usr/sbin/lsof" : null);
119
+ if (!lsof) return null;
120
+
121
+ const selector = `-iTCP:${port}`;
122
+ const result = await $`${lsof} -nP ${selector} -sTCP:LISTEN -Fpc`.quiet().nothrow();
123
+ if (result.exitCode !== 0) return null;
124
+
125
+ let pid: number | null = null;
126
+ let image = "unknown";
127
+ for (const line of result.text().split("\n")) {
128
+ if (line.startsWith("p")) {
129
+ const parsed = Number.parseInt(line.slice(1), 10);
130
+ pid = Number.isSafeInteger(parsed) ? parsed : null;
131
+ } else if (line.startsWith("c") && pid !== null) {
132
+ image = line.slice(1) || "unknown";
133
+ break;
134
+ }
135
+ }
136
+ if (pid === null) return null;
137
+
138
+ const ps = $which("ps");
139
+ if (!ps) return { pid, image, commandLine: "" };
140
+ const processInfo = await $`${ps} -ww -p ${pid} -o command=`.quiet().nothrow();
141
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
142
+ }
143
+
144
+ async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
145
+ const netstat = $which("netstat");
146
+ if (!netstat) return null;
147
+
148
+ const result = await $`${netstat} -ano -p TCP`.quiet().nothrow();
149
+ if (result.exitCode !== 0) return null;
150
+
151
+ let pid: number | null = null;
152
+ for (const line of result.text().split("\n")) {
153
+ const fields = line.trim().split(/\s+/);
154
+ if (fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING") continue;
155
+ const localAddress = fields[1];
156
+ if (!localAddress || Number.parseInt(localAddress.slice(localAddress.lastIndexOf(":") + 1), 10) !== port) {
157
+ continue;
158
+ }
159
+ const parsed = Number.parseInt(fields[4] ?? "", 10);
160
+ if (Number.isSafeInteger(parsed)) {
161
+ pid = parsed;
162
+ break;
163
+ }
164
+ }
165
+ if (pid === null) return null;
166
+
167
+ let image = "unknown";
168
+ const tasklist = $which("tasklist");
169
+ if (tasklist) {
170
+ const filter = `PID eq ${pid}`;
171
+ const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
172
+ if (task.exitCode === 0) {
173
+ const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
174
+ image = imageMatch?.[1]?.replaceAll('""', '"') || "unknown";
175
+ }
176
+ }
177
+
178
+ const powershell = $which("powershell") ?? $which("pwsh");
179
+ if (!powershell) return { pid, image, commandLine: "" };
180
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
181
+ const processInfo = await $`${powershell} -NoProfile -NonInteractive -Command ${command}`.quiet().nothrow();
182
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
183
+ }
184
+
185
+ async function findPortHolder(port: number): Promise<PortHolder | null> {
186
+ if (process.platform === "linux") return findLinuxPortHolder(port);
187
+ if (process.platform === "darwin") return findMacPortHolder(port);
188
+ if (process.platform === "win32") return findWindowsPortHolder(port);
189
+ return null;
190
+ }
191
+
192
+ async function terminatePortHolder(holder: PortHolder): Promise<void> {
193
+ try {
194
+ process.kill(holder.pid, "SIGTERM");
195
+ } catch (error) {
196
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
197
+ throw new Error(`Failed to stop ${holder.image} (PID ${holder.pid})`, { cause: error });
198
+ }
199
+
200
+ for (let attempt = 0; attempt < PROCESS_EXIT_POLLS; attempt++) {
201
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
202
+ try {
203
+ process.kill(holder.pid, 0);
204
+ } catch (error) {
205
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
206
+ throw new Error(`Failed to inspect ${holder.image} (PID ${holder.pid})`, { cause: error });
207
+ }
208
+ }
209
+
210
+ try {
211
+ process.kill(holder.pid, "SIGKILL");
212
+ } catch (error) {
213
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
214
+ throw new Error(`Failed to kill ${holder.image} (PID ${holder.pid})`, { cause: error });
215
+ }
216
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
217
+ }
218
+
219
+ /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
220
+ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
221
+ if (await probeStatsDashboard(port)) return "reuse";
222
+
223
+ const holder = await findPortHolder(port);
224
+ if (!holder) {
225
+ throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
226
+ }
227
+ if (holder.pid === process.pid) {
228
+ throw new Error(`Port ${port} is held by the current process (${holder.image}, PID ${holder.pid}).`);
229
+ }
230
+
231
+ const normalizedImage = holder.image
232
+ .toLowerCase()
233
+ .replace(/\.exe$/, "")
234
+ .replace(/ \(deleted\)$/, "");
235
+ const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");
236
+ const hasStatsIdentity =
237
+ normalizedImage === "omp-stats" ||
238
+ /(?:^|[/"'\s])omp-stats(?:\.exe)?(?:["'\s]|$)/.test(normalizedCommand) ||
239
+ /\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
240
+ (normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
241
+ /(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
242
+ if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
243
+ throw new Error(
244
+ `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
245
+ );
246
+ }
247
+
248
+ await terminatePortHolder(holder);
249
+ return "retry";
250
+ }