@kitsunemori96/opencode-session-metrics 0.0.0-stage → 0.1.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/bin/osm.js ADDED
@@ -0,0 +1,403 @@
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 { 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 stop stop the background server
47
+ osm status process, URL, data dir, source db, bundle age
48
+ osm logs [-n N] follow the server log (default: last 40 lines)
49
+ osm open open the browser only
50
+
51
+ Options
52
+ --port <n> listen port (default: $OSM_PORT or 8787)
53
+ --host <addr> bind address (default: $OSM_HOST or 127.0.0.1)
54
+ --data <dir> data dir (default: $OSM_DATA_DIR or the XDG data dir)
55
+ --source <db> OpenCode database (default: $OSM_SOURCE_DB or ~/.local/share/opencode/opencode.db)
56
+ --no-open do not open a browser
57
+ -f, --foreground same as \`osm run\`
58
+ -v, --version print the version
59
+ -h, --help print this help
60
+ `;
61
+
62
+ const argv = process.argv.slice(2);
63
+ const flags = { open: true, foreground: false };
64
+ const positional = [];
65
+ let logLines = 40;
66
+
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ const arg = argv[i];
69
+ switch (arg) {
70
+ case "-h":
71
+ case "--help":
72
+ positional.push("help");
73
+ break;
74
+ case "-v":
75
+ case "--version":
76
+ positional.push("version");
77
+ break;
78
+ case "--port":
79
+ flags.port = Number(argv[(i += 1)]);
80
+ break;
81
+ case "--host":
82
+ flags.host = argv[(i += 1)];
83
+ break;
84
+ case "--data":
85
+ flags.data = argv[(i += 1)];
86
+ break;
87
+ case "--source":
88
+ flags.source = argv[(i += 1)];
89
+ break;
90
+ case "--no-open":
91
+ flags.open = false;
92
+ break;
93
+ case "--open":
94
+ flags.open = true;
95
+ break;
96
+ case "-f":
97
+ case "--foreground":
98
+ flags.foreground = true;
99
+ break;
100
+ case "-n":
101
+ logLines = Number(argv[(i += 1)]);
102
+ break;
103
+ default:
104
+ if (arg.startsWith("-")) fail(`unknown option: ${arg} (try: osm --help)`);
105
+ positional.push(arg);
106
+ }
107
+ }
108
+
109
+ const command = positional[0] ?? "start";
110
+ if (positional.length > 1) fail(`unexpected argument: ${positional[1]}`);
111
+
112
+ const HOST = flags.host ?? process.env.OSM_HOST ?? "127.0.0.1";
113
+ const PORT = flags.port ?? Number(process.env.OSM_PORT ?? 8787);
114
+ if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) fail(`invalid port: ${flags.port ?? process.env.OSM_PORT}`);
115
+ // Same rule as server/src/config.ts: a checkout keeps state in `<repo>/.data`,
116
+ // a global install goes to the XDG data dir.
117
+ const INSTALLED = `${path.sep}node_modules${path.sep}`;
118
+ // Fixed data-dir name: the package is scoped, but a scope must not become a
119
+ // subdirectory of the XDG data dir.
120
+ const DEFAULT_DATA_DIR = ROOT.includes(INSTALLED)
121
+ ? path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share"), "opencode-session-metrics")
122
+ : path.join(ROOT, ".data");
123
+ const DATA_DIR = path.resolve(flags.data ?? process.env.OSM_DATA_DIR ?? DEFAULT_DATA_DIR);
124
+ const SOURCE_DB =
125
+ flags.source ??
126
+ process.env.OSM_SOURCE_DB ??
127
+ path.join(homedir(), ".local", "share", "opencode", "opencode.db");
128
+ const BROWSE_HOST = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
129
+ const URL = `http://${BROWSE_HOST.includes(":") ? `[${BROWSE_HOST}]` : BROWSE_HOST}:${PORT}`;
130
+
131
+ const PID_FILE = path.join(DATA_DIR, "osm.pid");
132
+ const LOG_FILE = path.join(DATA_DIR, "osm.log");
133
+
134
+ function fail(message) {
135
+ process.stderr.write(`osm: ${message}\n`);
136
+ process.exit(1);
137
+ }
138
+
139
+ function note(message) {
140
+ process.stdout.write(`osm: ${message}\n`);
141
+ }
142
+
143
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
144
+
145
+ async function isUp(timeout = 1200) {
146
+ try {
147
+ const res = await fetch(`${URL}/api/status`, { signal: AbortSignal.timeout(timeout) });
148
+ return res.ok;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ async function waitFor(state, ms) {
155
+ const until = Date.now() + ms;
156
+ while (Date.now() < until) {
157
+ if ((await isUp(700)) === state) return true;
158
+ await sleep(250);
159
+ }
160
+ return (await isUp(700)) === state;
161
+ }
162
+
163
+ function pidAlive(pid) {
164
+ try {
165
+ process.kill(pid, 0);
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function readPid() {
173
+ try {
174
+ const pid = Number(readFileSync(PID_FILE, "utf8").trim());
175
+ return Number.isInteger(pid) && pid > 0 && pidAlive(pid) ? pid : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /** A recycled pid must not cost an unrelated process its life. */
182
+ function isOurServer(pid) {
183
+ if (process.platform !== "linux") return true;
184
+ try {
185
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
186
+ return cmdline.includes("server/dist/index.js") || cmdline.includes("server/src/index.ts");
187
+ } catch {
188
+ return true;
189
+ }
190
+ }
191
+
192
+ function onPath(bin) {
193
+ return (process.env.PATH ?? "")
194
+ .split(path.delimiter)
195
+ .some((dir) => dir && existsSync(path.join(dir, bin)));
196
+ }
197
+
198
+ function openBrowser() {
199
+ if (!flags.open) {
200
+ note(`open ${URL}`);
201
+ return;
202
+ }
203
+ let cmd;
204
+ if (process.platform === "darwin") cmd = ["open", [URL]];
205
+ else if (process.platform === "win32") cmd = ["cmd", ["/c", "start", "", URL]];
206
+ else if (existsSync("/proc/version") && readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft") && onPath("wslview"))
207
+ cmd = ["wslview", [URL]];
208
+ else if (onPath("xdg-open")) cmd = ["xdg-open", [URL]];
209
+ if (!cmd) {
210
+ note(`open ${URL}`);
211
+ return;
212
+ }
213
+ const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
214
+ child.on("error", () => note(`open ${URL}`));
215
+ child.unref();
216
+ }
217
+
218
+ function childEnv() {
219
+ return {
220
+ ...process.env,
221
+ OSM_HOST: HOST,
222
+ OSM_PORT: String(PORT),
223
+ OSM_DATA_DIR: DATA_DIR,
224
+ OSM_SOURCE_DB: SOURCE_DB,
225
+ };
226
+ }
227
+
228
+ function lastLines(file, count) {
229
+ const size = statSync(file).size;
230
+ const start = Math.max(0, size - 64 * 1024);
231
+ const lines = readFileSync(file).subarray(start).toString("utf8").split("\n");
232
+ if (start > 0) lines.shift();
233
+ return lines.slice(-count).join("\n").trimEnd();
234
+ }
235
+
236
+ function checkInstall() {
237
+ if (!existsSync(SERVER_ENTRY)) fail(`server bundle missing (${SERVER_ENTRY}) — reinstall: npm i -g ${PKG.name}`);
238
+ if (!existsSync(WEB_INDEX)) fail(`web bundle missing (${WEB_INDEX}) — reinstall: npm i -g ${PKG.name}`);
239
+ }
240
+
241
+ async function start(foreground) {
242
+ checkInstall();
243
+
244
+ if (await isUp()) {
245
+ note(`already running — ${URL}`);
246
+ openBrowser();
247
+ return;
248
+ }
249
+
250
+ const stale = readPid();
251
+ if (stale) {
252
+ if (!isOurServer(stale)) {
253
+ note(`pid ${stale} is not this server — clearing ${PID_FILE}`);
254
+ try {
255
+ unlinkSync(PID_FILE);
256
+ } catch {}
257
+ } else if (await waitFor(true, 15000)) {
258
+ note(`up — ${URL}`);
259
+ openBrowser();
260
+ return;
261
+ } else {
262
+ fail(`pid ${stale} is alive but ${URL} does not answer — osm stop, then retry`);
263
+ }
264
+ }
265
+
266
+ mkdirSync(DATA_DIR, { recursive: true });
267
+
268
+ if (foreground) {
269
+ note(`serving ${URL} (data: ${DATA_DIR}) — Ctrl-C to stop`);
270
+ const child = spawn(process.execPath, [SERVER_ENTRY], { stdio: "inherit", env: childEnv(), cwd: ROOT });
271
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
272
+ process.on(signal, () => child.kill(signal));
273
+ }
274
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
275
+ return;
276
+ }
277
+
278
+ const log = openSync(LOG_FILE, "a");
279
+ const child = spawn(process.execPath, [SERVER_ENTRY], {
280
+ detached: true,
281
+ stdio: ["ignore", log, log],
282
+ env: childEnv(),
283
+ cwd: ROOT,
284
+ });
285
+ child.unref();
286
+ writeFileSync(PID_FILE, `${child.pid}\n`);
287
+
288
+ if (!(await waitFor(true, 20000))) {
289
+ const tail = existsSync(LOG_FILE) ? lastLines(LOG_FILE, 20) : "";
290
+ try {
291
+ process.kill(child.pid, "SIGTERM");
292
+ } catch {}
293
+ fail(`server did not come up (log: ${LOG_FILE})${tail ? `\n${tail}` : ""}`);
294
+ }
295
+
296
+ note(`up — ${URL} (pid ${child.pid}, data ${DATA_DIR})`);
297
+ openBrowser();
298
+ }
299
+
300
+ async function stop() {
301
+ const pid = readPid();
302
+ if (!pid) {
303
+ if (await isUp()) {
304
+ note(`${URL} answers, but it was not started by osm — leaving it alone`);
305
+ return;
306
+ }
307
+ note("not running");
308
+ try {
309
+ unlinkSync(PID_FILE);
310
+ } catch {}
311
+ return;
312
+ }
313
+
314
+ if (!isOurServer(pid)) {
315
+ note(`pid ${pid} is not this server — leaving it alone`);
316
+ try {
317
+ unlinkSync(PID_FILE);
318
+ } catch {}
319
+ return;
320
+ }
321
+
322
+ try {
323
+ process.kill(pid, "SIGTERM");
324
+ } catch {}
325
+ if (!(await waitFor(false, 8000))) {
326
+ try {
327
+ process.kill(pid, "SIGKILL");
328
+ } catch {}
329
+ await waitFor(false, 2000);
330
+ }
331
+ try {
332
+ unlinkSync(PID_FILE);
333
+ } catch {}
334
+ note(`stopped (pid ${pid})`);
335
+ }
336
+
337
+ async function status() {
338
+ const pid = readPid();
339
+ const up = await isUp();
340
+ note(`${up ? "up" : "down"} — ${URL}`);
341
+ if (pid) note(` pid ${pid}`);
342
+ else if (up) note(" pid unknown (not started by osm)");
343
+ note(` data ${DATA_DIR}${existsSync(path.join(DATA_DIR, "metrics.db")) ? "" : " (no metrics.db yet)"}`);
344
+ note(` source ${SOURCE_DB}${existsSync(SOURCE_DB) ? "" : " (missing)"}`);
345
+ if (existsSync(WEB_INDEX)) {
346
+ note(` bundle ${statSync(WEB_INDEX).mtime.toISOString().slice(0, 16).replace("T", " ")}`);
347
+ } else {
348
+ note(" bundle missing");
349
+ }
350
+ }
351
+
352
+ function follow() {
353
+ if (!existsSync(LOG_FILE)) {
354
+ note(`no log yet (${LOG_FILE})`);
355
+ return;
356
+ }
357
+ process.stdout.write(`${lastLines(LOG_FILE, logLines)}\n`);
358
+ let size = statSync(LOG_FILE).size;
359
+ watchFile(LOG_FILE, { interval: 400 }, (current) => {
360
+ if (current.size < size) size = 0;
361
+ if (current.size <= size) return;
362
+ createReadStream(LOG_FILE, { start: size, end: current.size - 1 }).pipe(process.stdout, { end: false });
363
+ size = current.size;
364
+ });
365
+ }
366
+
367
+ async function restart() {
368
+ await stop();
369
+ await start(flags.foreground);
370
+ }
371
+
372
+ switch (command) {
373
+ case "start":
374
+ await start(flags.foreground);
375
+ break;
376
+ case "run":
377
+ await start(true);
378
+ break;
379
+ case "restart":
380
+ await restart();
381
+ break;
382
+ case "stop":
383
+ await stop();
384
+ break;
385
+ case "status":
386
+ await status();
387
+ break;
388
+ case "logs":
389
+ case "log":
390
+ follow();
391
+ break;
392
+ case "open":
393
+ openBrowser();
394
+ break;
395
+ case "version":
396
+ process.stdout.write(`${PKG.version}\n`);
397
+ break;
398
+ case "help":
399
+ process.stdout.write(USAGE);
400
+ break;
401
+ default:
402
+ fail(`unknown command: ${command} (try: osm --help)`);
403
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@kitsunemori96/opencode-session-metrics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ "materialize": "pnpm -F @osm/server materialize",
44
+ "start": "pnpm -F @osm/server start",
45
+ "typecheck": "pnpm -r typecheck",
46
+ "test": "pnpm -r test"
47
+ },
48
+ "dependencies": {
49
+ "cors": "^2.8.5",
50
+ "express": "^5.1.0"
51
+ },
52
+ "devDependencies": {
53
+ "concurrently": "^9.2.1"
54
+ }
55
+ }