@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.
- package/CHANGELOG.md +12 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +63 -63
- package/dist/client/styles.css +40 -55
- package/dist/types/client/data/view-models.d.ts +10 -3
- package/dist/types/port-conflict.d.ts +4 -0
- package/dist/types/server.d.ts +1 -1
- package/package.json +4 -4
- package/src/client/data/view-models.ts +16 -8
- package/src/client/routes/OverviewRoute.tsx +2 -2
- package/src/client/styles.css +4 -4
- package/src/client/ui/MetricCluster.tsx +13 -2
- package/src/port-conflict.ts +250 -0
- package/src/server.ts +42 -14
package/dist/client/styles.css
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
/*! tailwindcss v4.3.
|
|
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:
|
|
7
|
-
"
|
|
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,
|
|
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:
|
|
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
|
-
:
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
:
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
:
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
:
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
:
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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(
|
|
962
|
+
grid-template-columns: repeat(8, 1fr);
|
|
978
963
|
gap: 12px;
|
|
979
964
|
}
|
|
980
|
-
@media (max-width:
|
|
965
|
+
@media (max-width: 1279px) {
|
|
981
966
|
.stats-metric-secondary-grid {
|
|
982
|
-
grid-template-columns: repeat(
|
|
967
|
+
grid-template-columns: repeat(4, 1fr);
|
|
983
968
|
}
|
|
984
969
|
}
|
|
985
|
-
@media (max-width:
|
|
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
|
|
20
|
-
*
|
|
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">;
|
package/dist/types/server.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
43
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
44
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
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
|
|
37
|
-
*
|
|
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 +
|
|
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 =
|
|
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="
|
|
230
|
-
subtitle="
|
|
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} />}
|
package/src/client/styles.css
CHANGED
|
@@ -581,17 +581,17 @@
|
|
|
581
581
|
|
|
582
582
|
.stats-metric-secondary-grid {
|
|
583
583
|
display: grid;
|
|
584
|
-
grid-template-columns: repeat(
|
|
584
|
+
grid-template-columns: repeat(8, 1fr);
|
|
585
585
|
gap: 12px;
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
-
@media (max-width:
|
|
588
|
+
@media (max-width: 1279px) {
|
|
589
589
|
.stats-metric-secondary-grid {
|
|
590
|
-
grid-template-columns: repeat(
|
|
590
|
+
grid-template-columns: repeat(4, 1fr);
|
|
591
591
|
}
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
@media (max-width:
|
|
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
|
|
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
|
+
}
|