@kitsunemori96/opencode-session-metrics 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +409 -2
- package/bin/osm.js +486 -0
- package/package.json +55 -4
- package/server/dist/index.js +3892 -0
- package/web/dist/assets/index-CXuzIF_g.css +1 -0
- package/web/dist/assets/index-Dr7sP5of.js +228 -0
- package/web/dist/assets/index-Dr7sP5of.js.map +1 -0
- package/web/dist/fonts/LICENSE-symbols.txt +76 -0
- package/web/dist/fonts/instrument-symbols.woff2 +0 -0
- package/web/dist/fonts/inter-latin.woff2 +0 -0
- package/web/dist/fonts/jetbrains-mono.woff2 +0 -0
- package/web/dist/index.html +18 -0
package/bin/osm.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// osm — start the session-metrics cockpit from anywhere.
|
|
3
|
+
//
|
|
4
|
+
// osm start the server in the background and open the browser
|
|
5
|
+
// osm run run in the foreground (Ctrl-C to stop)
|
|
6
|
+
// osm open open the browser only
|
|
7
|
+
// osm stop stop the background server
|
|
8
|
+
// osm status what is running, where the data lives
|
|
9
|
+
// osm logs follow the server log
|
|
10
|
+
//
|
|
11
|
+
// One process, one port: Express serves both the API and the built web bundle.
|
|
12
|
+
// State goes to the XDG data dir (`~/.local/share/opencode-session-metrics`), never
|
|
13
|
+
// into the package directory, so `npm i -g` upgrades cannot delete it.
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
createReadStream,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
statSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
watchFile,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
const PKG = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
32
|
+
const SERVER_ENTRY = path.join(ROOT, "server", "dist", "index.js");
|
|
33
|
+
const WEB_INDEX = path.join(ROOT, "web", "dist", "index.html");
|
|
34
|
+
|
|
35
|
+
// `osm status | head` must exit quietly instead of throwing EPIPE.
|
|
36
|
+
process.stdout.on("error", (error) => {
|
|
37
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const USAGE = `osm ${PKG.version} — local web cockpit for OpenCode session metrics
|
|
42
|
+
|
|
43
|
+
Usage
|
|
44
|
+
osm [start] [options] start in the background, then open the browser
|
|
45
|
+
osm run [options] run in the foreground (Ctrl-C to stop)
|
|
46
|
+
osm restart [options] stop, then start again
|
|
47
|
+
osm stop stop the background server
|
|
48
|
+
osm status process, URL, data dir, source db, bundle age
|
|
49
|
+
osm logs [-n N] follow the server log (default: last 40 lines)
|
|
50
|
+
osm open open the browser only
|
|
51
|
+
|
|
52
|
+
Options
|
|
53
|
+
--port <n> listen port (default: $OSM_PORT or 8787)
|
|
54
|
+
--host <addr> bind address (default: $OSM_HOST or 127.0.0.1)
|
|
55
|
+
--data <dir> data dir (default: $OSM_DATA_DIR or the XDG data dir)
|
|
56
|
+
--source <db> OpenCode database (default: $OSM_SOURCE_DB or ~/.local/share/opencode/opencode.db)
|
|
57
|
+
--no-open do not open a browser
|
|
58
|
+
-f, --foreground same as \`osm run\`
|
|
59
|
+
-v, --version print the version
|
|
60
|
+
-h, --help print this help
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const argv = process.argv.slice(2);
|
|
64
|
+
const flags = { open: true, foreground: false };
|
|
65
|
+
const positional = [];
|
|
66
|
+
let logLines = 40;
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
69
|
+
const arg = argv[i];
|
|
70
|
+
switch (arg) {
|
|
71
|
+
// `pnpm serve -- --no-open` forwards the separator verbatim.
|
|
72
|
+
case "--":
|
|
73
|
+
break;
|
|
74
|
+
case "-h":
|
|
75
|
+
case "--help":
|
|
76
|
+
positional.push("help");
|
|
77
|
+
break;
|
|
78
|
+
case "-v":
|
|
79
|
+
case "--version":
|
|
80
|
+
positional.push("version");
|
|
81
|
+
break;
|
|
82
|
+
case "--port":
|
|
83
|
+
flags.port = Number(argv[(i += 1)]);
|
|
84
|
+
break;
|
|
85
|
+
case "--host":
|
|
86
|
+
flags.host = argv[(i += 1)];
|
|
87
|
+
break;
|
|
88
|
+
case "--data":
|
|
89
|
+
flags.data = argv[(i += 1)];
|
|
90
|
+
break;
|
|
91
|
+
case "--source":
|
|
92
|
+
flags.source = argv[(i += 1)];
|
|
93
|
+
break;
|
|
94
|
+
case "--no-open":
|
|
95
|
+
flags.open = false;
|
|
96
|
+
break;
|
|
97
|
+
case "--open":
|
|
98
|
+
flags.open = true;
|
|
99
|
+
break;
|
|
100
|
+
case "-f":
|
|
101
|
+
case "--foreground":
|
|
102
|
+
flags.foreground = true;
|
|
103
|
+
break;
|
|
104
|
+
case "-n":
|
|
105
|
+
logLines = Number(argv[(i += 1)]);
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
if (arg.startsWith("-")) fail(`unknown option: ${arg} (try: osm --help)`);
|
|
109
|
+
positional.push(arg);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const command = positional[0] ?? "start";
|
|
114
|
+
if (positional.length > 1) fail(`unexpected argument: ${positional[1]}`);
|
|
115
|
+
|
|
116
|
+
const HOST = flags.host ?? process.env.OSM_HOST ?? "127.0.0.1";
|
|
117
|
+
const PORT = flags.port ?? Number(process.env.OSM_PORT ?? 8787);
|
|
118
|
+
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) fail(`invalid port: ${flags.port ?? process.env.OSM_PORT}`);
|
|
119
|
+
// Same rule as server/src/config.ts: a checkout keeps state in `<repo>/.data`,
|
|
120
|
+
// a global install goes to the XDG data dir.
|
|
121
|
+
const INSTALLED = `${path.sep}node_modules${path.sep}`;
|
|
122
|
+
// Fixed data-dir name: the package is scoped, but a scope must not become a
|
|
123
|
+
// subdirectory of the XDG data dir.
|
|
124
|
+
const DEFAULT_DATA_DIR = ROOT.includes(INSTALLED)
|
|
125
|
+
? path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share"), "opencode-session-metrics")
|
|
126
|
+
: path.join(ROOT, ".data");
|
|
127
|
+
const DATA_DIR = path.resolve(flags.data ?? process.env.OSM_DATA_DIR ?? DEFAULT_DATA_DIR);
|
|
128
|
+
const SOURCE_DB =
|
|
129
|
+
flags.source ??
|
|
130
|
+
process.env.OSM_SOURCE_DB ??
|
|
131
|
+
path.join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
132
|
+
const BROWSE_HOST = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
|
|
133
|
+
const URL = `http://${BROWSE_HOST.includes(":") ? `[${BROWSE_HOST}]` : BROWSE_HOST}:${PORT}`;
|
|
134
|
+
|
|
135
|
+
const PID_FILE = path.join(DATA_DIR, "osm.pid");
|
|
136
|
+
const LOG_FILE = path.join(DATA_DIR, "osm.log");
|
|
137
|
+
|
|
138
|
+
function fail(message) {
|
|
139
|
+
process.stderr.write(`osm: ${message}\n`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function note(message) {
|
|
144
|
+
process.stdout.write(`osm: ${message}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
148
|
+
|
|
149
|
+
async function isUp(timeout = 1200) {
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`${URL}/api/status`, { signal: AbortSignal.timeout(timeout) });
|
|
152
|
+
return res.ok;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function waitFor(state, ms) {
|
|
159
|
+
const until = Date.now() + ms;
|
|
160
|
+
while (Date.now() < until) {
|
|
161
|
+
if ((await isUp(700)) === state) return true;
|
|
162
|
+
await sleep(250);
|
|
163
|
+
}
|
|
164
|
+
return (await isUp(700)) === state;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pidAlive(pid) {
|
|
168
|
+
try {
|
|
169
|
+
process.kill(pid, 0);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readPid() {
|
|
177
|
+
try {
|
|
178
|
+
const pid = Number(readFileSync(PID_FILE, "utf8").trim());
|
|
179
|
+
return Number.isInteger(pid) && pid > 0 && pidAlive(pid) ? pid : null;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A recycled pid must not cost an unrelated process its life. */
|
|
186
|
+
function isOurServer(pid) {
|
|
187
|
+
if (process.platform !== "linux") return true;
|
|
188
|
+
try {
|
|
189
|
+
const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
|
190
|
+
return cmdline.includes("server/dist/index.js") || cmdline.includes("server/src/index.ts");
|
|
191
|
+
} catch {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Who holds the port, when we can see it without being root.
|
|
198
|
+
*
|
|
199
|
+
* This exists because a server started by hand (`node server/dist/index.js &`)
|
|
200
|
+
* writes no pid file, and `osm stop` would then refuse to touch it — leaving a
|
|
201
|
+
* stale bundle answering on the port while `osm start` says "already running".
|
|
202
|
+
* That is exactly the mismatch `pnpm serve` must not inherit.
|
|
203
|
+
*/
|
|
204
|
+
function listenerPid() {
|
|
205
|
+
const run = (bin, args) => {
|
|
206
|
+
try {
|
|
207
|
+
return execFileSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
208
|
+
} catch {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
let out = "";
|
|
213
|
+
if (process.platform === "linux") {
|
|
214
|
+
for (const bin of ["ss", "/usr/sbin/ss", "/bin/ss", "/usr/bin/ss"]) {
|
|
215
|
+
out = run(bin, ["-ltnpH", `sport = :${PORT}`]);
|
|
216
|
+
if (out) break;
|
|
217
|
+
}
|
|
218
|
+
const hit = out.match(/pid=(\d+)/);
|
|
219
|
+
return hit ? Number(hit[1]) : null;
|
|
220
|
+
}
|
|
221
|
+
if (process.platform === "darwin") {
|
|
222
|
+
out = run("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"]);
|
|
223
|
+
return Number(out.trim().split("\n")[0]) || null;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onPath(bin) {
|
|
229
|
+
return (process.env.PATH ?? "")
|
|
230
|
+
.split(path.delimiter)
|
|
231
|
+
.some((dir) => dir && existsSync(path.join(dir, bin)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function openBrowser() {
|
|
235
|
+
if (!flags.open) {
|
|
236
|
+
note(`open ${URL}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
let cmd;
|
|
240
|
+
if (process.platform === "darwin") cmd = ["open", [URL]];
|
|
241
|
+
else if (process.platform === "win32") cmd = ["cmd", ["/c", "start", "", URL]];
|
|
242
|
+
else if (existsSync("/proc/version") && readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft") && onPath("wslview"))
|
|
243
|
+
cmd = ["wslview", [URL]];
|
|
244
|
+
else if (onPath("xdg-open")) cmd = ["xdg-open", [URL]];
|
|
245
|
+
if (!cmd) {
|
|
246
|
+
note(`open ${URL}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
|
|
250
|
+
child.on("error", () => note(`open ${URL}`));
|
|
251
|
+
child.unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function childEnv() {
|
|
255
|
+
return {
|
|
256
|
+
...process.env,
|
|
257
|
+
OSM_HOST: HOST,
|
|
258
|
+
OSM_PORT: String(PORT),
|
|
259
|
+
OSM_DATA_DIR: DATA_DIR,
|
|
260
|
+
OSM_SOURCE_DB: SOURCE_DB,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* When a pid started, as epoch ms (Linux only).
|
|
266
|
+
*
|
|
267
|
+
* `status` uses it for the one staleness question that matters: a process older
|
|
268
|
+
* than `web/dist` is serving a bundle that no longer exists on disk — the panel
|
|
269
|
+
* runs, the page renders, and only the API is from last week.
|
|
270
|
+
*/
|
|
271
|
+
function startedAt(pid) {
|
|
272
|
+
if (process.platform !== "linux") return null;
|
|
273
|
+
try {
|
|
274
|
+
// Field 22 is starttime in USER_HZ ticks since boot. `comm` may contain
|
|
275
|
+
// spaces, so the fields resume after the last ")".
|
|
276
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
277
|
+
const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
278
|
+
const uptime = Number(readFileSync("/proc/uptime", "utf8").split(" ")[0]);
|
|
279
|
+
return Date.now() - (uptime - ticks / 100) * 1000;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function lastLines(file, count) {
|
|
286
|
+
const size = statSync(file).size;
|
|
287
|
+
const start = Math.max(0, size - 64 * 1024);
|
|
288
|
+
const lines = readFileSync(file).subarray(start).toString("utf8").split("\n");
|
|
289
|
+
if (start > 0) lines.shift();
|
|
290
|
+
return lines.slice(-count).join("\n").trimEnd();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function checkInstall() {
|
|
294
|
+
if (!existsSync(SERVER_ENTRY)) fail(`server bundle missing (${SERVER_ENTRY}) — reinstall: npm i -g ${PKG.name}`);
|
|
295
|
+
if (!existsSync(WEB_INDEX)) fail(`web bundle missing (${WEB_INDEX}) — reinstall: npm i -g ${PKG.name}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function start(foreground) {
|
|
299
|
+
checkInstall();
|
|
300
|
+
|
|
301
|
+
if (await isUp()) {
|
|
302
|
+
note(`already running — ${URL}`);
|
|
303
|
+
const holder = readPid() ?? listenerPid();
|
|
304
|
+
const since = holder ? startedAt(holder) : null;
|
|
305
|
+
if (since && existsSync(WEB_INDEX) && statSync(WEB_INDEX).mtime.getTime() - since > 60000) {
|
|
306
|
+
note("the bundle on disk is newer than the running process — osm restart (or pnpm serve) to load it");
|
|
307
|
+
}
|
|
308
|
+
openBrowser();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const stale = readPid();
|
|
313
|
+
if (stale) {
|
|
314
|
+
if (!isOurServer(stale)) {
|
|
315
|
+
note(`pid ${stale} is not this server — clearing ${PID_FILE}`);
|
|
316
|
+
try {
|
|
317
|
+
unlinkSync(PID_FILE);
|
|
318
|
+
} catch {}
|
|
319
|
+
} else if (await waitFor(true, 15000)) {
|
|
320
|
+
note(`up — ${URL}`);
|
|
321
|
+
openBrowser();
|
|
322
|
+
return;
|
|
323
|
+
} else {
|
|
324
|
+
fail(`pid ${stale} is alive but ${URL} does not answer — osm stop, then retry`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
329
|
+
|
|
330
|
+
if (foreground) {
|
|
331
|
+
note(`serving ${URL} (data: ${DATA_DIR}) — Ctrl-C to stop`);
|
|
332
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], { stdio: "inherit", env: childEnv(), cwd: ROOT });
|
|
333
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
334
|
+
process.on(signal, () => child.kill(signal));
|
|
335
|
+
}
|
|
336
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const log = openSync(LOG_FILE, "a");
|
|
341
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
|
342
|
+
detached: true,
|
|
343
|
+
stdio: ["ignore", log, log],
|
|
344
|
+
env: childEnv(),
|
|
345
|
+
cwd: ROOT,
|
|
346
|
+
});
|
|
347
|
+
child.unref();
|
|
348
|
+
writeFileSync(PID_FILE, `${child.pid}\n`);
|
|
349
|
+
|
|
350
|
+
if (!(await waitFor(true, 20000))) {
|
|
351
|
+
const tail = existsSync(LOG_FILE) ? lastLines(LOG_FILE, 20) : "";
|
|
352
|
+
try {
|
|
353
|
+
process.kill(child.pid, "SIGTERM");
|
|
354
|
+
} catch {}
|
|
355
|
+
fail(`server did not come up (log: ${LOG_FILE})${tail ? `\n${tail}` : ""}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
note(`up — ${URL} (pid ${child.pid}, data ${DATA_DIR})`);
|
|
359
|
+
openBrowser();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function stop() {
|
|
363
|
+
let pid = readPid();
|
|
364
|
+
let adopted = false;
|
|
365
|
+
if (!pid) {
|
|
366
|
+
// No pid file: the process may still be ours, just started by hand.
|
|
367
|
+
const holder = listenerPid();
|
|
368
|
+
if (holder && isOurServer(holder)) {
|
|
369
|
+
pid = holder;
|
|
370
|
+
adopted = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!pid) {
|
|
374
|
+
if (await isUp()) {
|
|
375
|
+
note(`${URL} answers, but it was not started by osm — leaving it alone`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
note("not running");
|
|
379
|
+
try {
|
|
380
|
+
unlinkSync(PID_FILE);
|
|
381
|
+
} catch {}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!isOurServer(pid)) {
|
|
386
|
+
note(`pid ${pid} is not this server — leaving it alone`);
|
|
387
|
+
try {
|
|
388
|
+
unlinkSync(PID_FILE);
|
|
389
|
+
} catch {}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
process.kill(pid, "SIGTERM");
|
|
395
|
+
} catch {}
|
|
396
|
+
if (!(await waitFor(false, 8000))) {
|
|
397
|
+
try {
|
|
398
|
+
process.kill(pid, "SIGKILL");
|
|
399
|
+
} catch {}
|
|
400
|
+
await waitFor(false, 2000);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
unlinkSync(PID_FILE);
|
|
404
|
+
} catch {}
|
|
405
|
+
note(`stopped (pid ${pid}${adopted ? ", stopped a server osm did not start" : ""})`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function status() {
|
|
409
|
+
const pid = readPid();
|
|
410
|
+
const up = await isUp();
|
|
411
|
+
// One lookup either way: a pid file is authoritative, the listener is the fallback.
|
|
412
|
+
const holder = pid ?? (up ? listenerPid() : null);
|
|
413
|
+
note(`${up ? "up" : "down"} — ${URL}`);
|
|
414
|
+
if (pid) note(` pid ${pid}`);
|
|
415
|
+
else if (up) note(` pid ${holder ? `${holder} (not started by osm)` : "unknown (not started by osm)"}`);
|
|
416
|
+
note(` data ${DATA_DIR}${existsSync(path.join(DATA_DIR, "metrics.db")) ? "" : " (no metrics.db yet)"}`);
|
|
417
|
+
note(` source ${SOURCE_DB}${existsSync(SOURCE_DB) ? "" : " (missing)"}`);
|
|
418
|
+
if (existsSync(WEB_INDEX)) {
|
|
419
|
+
// Local time, not ISO: the question this answers is "did the build I just ran
|
|
420
|
+
// land in the bundle the server is serving", and that comparison happens in
|
|
421
|
+
// the reader's timezone.
|
|
422
|
+
const built = statSync(WEB_INDEX).mtime;
|
|
423
|
+
const age = Math.round((Date.now() - built.getTime()) / 60000);
|
|
424
|
+
note(` bundle ${built.toLocaleString("sv-SE").slice(0, 16)} (${age}m old)`);
|
|
425
|
+
const since = holder ? startedAt(holder) : null;
|
|
426
|
+
// A minute of slack: the two timestamps come from different clocks.
|
|
427
|
+
if (since && built.getTime() - since > 60000) {
|
|
428
|
+
note(` stale pid ${holder} predates the bundle — restart it: pnpm serve`);
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
note(" bundle missing");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function follow() {
|
|
436
|
+
if (!existsSync(LOG_FILE)) {
|
|
437
|
+
note(`no log yet (${LOG_FILE})`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
process.stdout.write(`${lastLines(LOG_FILE, logLines)}\n`);
|
|
441
|
+
let size = statSync(LOG_FILE).size;
|
|
442
|
+
watchFile(LOG_FILE, { interval: 400 }, (current) => {
|
|
443
|
+
if (current.size < size) size = 0;
|
|
444
|
+
if (current.size <= size) return;
|
|
445
|
+
createReadStream(LOG_FILE, { start: size, end: current.size - 1 }).pipe(process.stdout, { end: false });
|
|
446
|
+
size = current.size;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function restart() {
|
|
451
|
+
await stop();
|
|
452
|
+
await start(flags.foreground);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
switch (command) {
|
|
456
|
+
case "start":
|
|
457
|
+
await start(flags.foreground);
|
|
458
|
+
break;
|
|
459
|
+
case "run":
|
|
460
|
+
await start(true);
|
|
461
|
+
break;
|
|
462
|
+
case "restart":
|
|
463
|
+
await restart();
|
|
464
|
+
break;
|
|
465
|
+
case "stop":
|
|
466
|
+
await stop();
|
|
467
|
+
break;
|
|
468
|
+
case "status":
|
|
469
|
+
await status();
|
|
470
|
+
break;
|
|
471
|
+
case "logs":
|
|
472
|
+
case "log":
|
|
473
|
+
follow();
|
|
474
|
+
break;
|
|
475
|
+
case "open":
|
|
476
|
+
openBrowser();
|
|
477
|
+
break;
|
|
478
|
+
case "version":
|
|
479
|
+
process.stdout.write(`${PKG.version}\n`);
|
|
480
|
+
break;
|
|
481
|
+
case "help":
|
|
482
|
+
process.stdout.write(USAGE);
|
|
483
|
+
break;
|
|
484
|
+
default:
|
|
485
|
+
fail(`unknown command: ${command} (try: osm --help)`);
|
|
486
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitsunemori96/opencode-session-metrics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A local-first web cockpit for OpenCode session metrics: tokens, cost, tools, and transcripts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "kitsuneMori96",
|
|
8
|
+
"homepage": "https://github.com/kitsuneMori96/opencode-session-metrics#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/kitsuneMori96/opencode-session-metrics.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kitsuneMori96/opencode-session-metrics/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"session",
|
|
19
|
+
"metrics",
|
|
20
|
+
"analytics",
|
|
21
|
+
"tokens",
|
|
22
|
+
"cost",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"osm": "bin/osm.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"server/dist/",
|
|
32
|
+
"web/dist/"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=24"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev": "concurrently -n server,web -c cyan,magenta \"pnpm -F @osm/server dev\" \"pnpm -F @osm/web dev\"",
|
|
39
|
+
"build": "pnpm -F @osm/web build && pnpm -F @osm/server build",
|
|
40
|
+
"build:web": "pnpm -F @osm/web build",
|
|
41
|
+
"build:server": "pnpm -F @osm/server build",
|
|
42
|
+
"prepack": "pnpm build",
|
|
43
|
+
"serve": "pnpm build && node bin/osm.js restart",
|
|
44
|
+
"materialize": "pnpm -F @osm/server materialize",
|
|
45
|
+
"plans": "pnpm -F @osm/server plans",
|
|
46
|
+
"start": "pnpm -F @osm/server start",
|
|
47
|
+
"typecheck": "pnpm -r typecheck",
|
|
48
|
+
"test": "pnpm -r test"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"cors": "^2.8.5",
|
|
52
|
+
"express": "^5.1.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"concurrently": "^9.2.1"
|
|
56
|
+
}
|
|
57
|
+
}
|