@oh-my-pi/omp-stats 18.13.3 → 18.14.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/src/server.ts CHANGED
@@ -474,19 +474,18 @@ function liveEventStream(headers: Record<string, string>, signal: AbortSignal):
474
474
  });
475
475
  }
476
476
 
477
- /**
478
- * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
479
- */
477
+ /** A dashboard this process started or found; `port` is where it listens, which may differ from the one requested. */
480
478
  export interface StatsServerHandle {
481
479
  hostname: string;
482
480
  port: number;
483
481
  stop: () => void;
484
482
  }
485
483
 
486
- // Dashboards this process already bound, keyed by requested `hostname:port`.
487
- // A second in-process start (e.g. `/trace` twice in one session) must return
488
- // the live handle: probing our own port can time out under load and would
489
- // then dead-end in the reclaim path's self-PID guard.
484
+ // Dashboards this process already bound, keyed by requested and bound
485
+ // `hostname:port`. A second in-process start (e.g. `/trace` twice in one
486
+ // session, or a caller passing back a fallback port) must return the live
487
+ // handle: probing our own port can time out under load and would then start
488
+ // yet another dashboard on a fallback port.
490
489
  const activeServers = new Map<string, StatsServerHandle>();
491
490
  /** Dashboards bound by this process (any port); background ingest stops when the last one does. */
492
491
  let liveServers = 0;
@@ -496,30 +495,34 @@ export interface StartServerOptions {
496
495
  judge?: StatsJudgeProvider;
497
496
  }
498
497
 
498
+ /**
499
+ * Start the dashboard on `port`, or reuse the current dashboard already serving it.
500
+ * An older dashboard there is stopped and replaced; any other holder (a foreign
501
+ * program, or a dashboard too busy to answer the identity probe) is left running
502
+ * and the dashboard binds an ephemeral port instead.
503
+ */
499
504
  export async function startServer(
500
505
  port = 3847,
501
506
  hostname = STATS_DASHBOARD_HOSTNAME,
502
507
  options: StartServerOptions = {},
503
508
  ): Promise<StatsServerHandle> {
504
509
  if (options.judge) setStatsJudgeProvider(options.judge);
505
- const activeKey = `${hostname}:${port}`;
510
+ const requestedKey = `${hostname}:${port}`;
506
511
  if (port !== 0) {
507
- const active = activeServers.get(activeKey);
512
+ const active = activeServers.get(requestedKey);
508
513
  if (active) return active;
509
514
  }
510
515
  await ensureClientBuild();
511
- const preparation = await prepareStatsPort(port, hostname);
512
- if (preparation === "reuse") {
513
- return { hostname, port, stop: () => {} };
514
- }
515
516
  const register = (server: Server<undefined>): StatsServerHandle => {
516
517
  liveServers++;
517
518
  let stopped = false;
519
+ const boundPort = server.port ?? port;
520
+ const keys = port === 0 ? [`${hostname}:${boundPort}`] : [requestedKey, `${hostname}:${boundPort}`];
518
521
  const handle: StatsServerHandle = {
519
522
  hostname,
520
- port: server.port ?? port,
523
+ port: boundPort,
521
524
  stop: () => {
522
- activeServers.delete(activeKey);
525
+ for (const key of keys) activeServers.delete(key);
523
526
  server.stop(true);
524
527
  if (stopped) return;
525
528
  stopped = true;
@@ -527,26 +530,31 @@ export async function startServer(
527
530
  if (--liveServers === 0) statsLive().stop();
528
531
  },
529
532
  };
530
- if (port !== 0) activeServers.set(activeKey, handle);
533
+ for (const key of keys) activeServers.set(key, handle);
531
534
  return handle;
532
535
  };
533
536
 
537
+ let claim = await prepareStatsPort(port, hostname);
538
+ if (claim === "bind") {
539
+ const server = bindDashboardServer(port, hostname);
540
+ if (server) return register(server);
541
+ claim = await recoverStatsPort(port, hostname);
542
+ }
543
+ if (claim === "reuse") return { hostname, port, stop: () => {} };
544
+ if (claim === "bind") {
545
+ const server = bindDashboardServer(port, hostname);
546
+ if (server) return register(server);
547
+ }
548
+ logger.warn("Stats dashboard port is held by another process; serving on an ephemeral port", { hostname, port });
549
+ return register(createDashboardServer(0, hostname));
550
+ }
551
+
552
+ /** {@link createDashboardServer}, or null when the port is already in use. */
553
+ function bindDashboardServer(port: number, hostname: string): Server<undefined> | null {
534
554
  try {
535
- return register(createDashboardServer(port, hostname));
555
+ return createDashboardServer(port, hostname);
536
556
  } catch (error) {
537
- if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
538
-
539
- const recovery = await recoverStatsPort(port, hostname);
540
- if (recovery === "reuse") {
541
- return { hostname, port, stop: () => {} };
542
- }
543
-
544
- try {
545
- return register(createDashboardServer(port, hostname));
546
- } catch (retryError) {
547
- throw new Error(`Failed to start stats dashboard on ${hostname}:${port} after reclaiming it.`, {
548
- cause: retryError,
549
- });
550
- }
557
+ if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") return null;
558
+ throw error;
551
559
  }
552
560
  }
@@ -604,7 +604,7 @@ export interface TraceTrack {
604
604
  agent: string | null;
605
605
  /** session_init.resolvedModel ?? first assistant model. */
606
606
  model: string | null;
607
- /** Absolute transcript path (for entry fetch). */
607
+ /** Absolute transcript path for live entry fetch, or the embedded snapshot key. */
608
608
  file: string;
609
609
  /** Sorted by start. */
610
610
  spans: TraceSpan[];
@@ -648,12 +648,13 @@ export interface SessionTrace {
648
648
  cwd: string | null;
649
649
  startedAt: number;
650
650
  endedAt: number;
651
- /** Root transcript mtime; doubles as the ETag. */
651
+ /** Root transcript mtime, or recorded root activity/header time for offline snapshots. */
652
652
  mtimeMs: number;
653
653
  /**
654
654
  * Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
655
655
  * traceFingerprintForEtag): the server ETag is built from this, so a
656
656
  * subagent-only append changes the ETag even when the root is untouched.
657
+ * Offline snapshots use "snapshot" and do not support HTTP revalidation.
657
658
  */
658
659
  etag: string;
659
660
  /** DFS order, main first. */
package/src/trace.ts CHANGED
@@ -35,7 +35,7 @@ export class TracePathError extends Error {}
35
35
  * span assembly changes so browsers don't revalidate stale cached bodies
36
36
  * against an unchanged transcript mtime.
37
37
  */
38
- export const TRACE_ETAG_VERSION = 3;
38
+ export const TRACE_ETAG_VERSION = 4;
39
39
 
40
40
  const MAX_TRACK_DEPTH = 6;
41
41
  const LABEL_MAX = 80;
@@ -230,7 +230,7 @@ function unionLength(intervals: Array<[number, number]>): number {
230
230
  // Single-transcript scan
231
231
 
232
232
  /** Keep only the active branch: the parentId chain walked back from the last entry. */
233
- function activeChain(views: EntryView[]): EntryView[] {
233
+ function activeChain(views: readonly EntryView[]): EntryView[] {
234
234
  const byId = new Map<string, EntryView>();
235
235
  let last: EntryView | undefined;
236
236
  for (const entry of views) {
@@ -278,13 +278,14 @@ function argsPreview(args: unknown): string | undefined {
278
278
  * markers, and the cross-track facts (task results, request counts).
279
279
  */
280
280
  function scanTranscript(
281
- entries: SessionEntry[],
281
+ entries: readonly SessionEntry[],
282
282
  trackId: string,
283
283
  parentTrackId: string | null,
284
284
  file: string,
285
+ header?: TraceSnapshotHeader | null,
285
286
  ): TrackScan {
286
287
  // Boundary cast: persisted JSONL entries are re-validated field-by-field below.
287
- const views = entries as EntryView[];
288
+ const views = entries as readonly EntryView[];
288
289
  const chain = activeChain(views);
289
290
  const spans: TraceSpan[] = [];
290
291
  const markers: TraceMarker[] = [];
@@ -297,9 +298,9 @@ function scanTranscript(
297
298
  const taskResults: TaskResultFact[] = [];
298
299
 
299
300
  let sessionExit: { recordedAt: number; pendingIds: Set<string> } | undefined;
300
- let cwd: string | null = null;
301
- let headerTitle: string | null = null;
302
- let headerTs: number | undefined;
301
+ let cwd = typeof header?.cwd === "string" ? header.cwd : null;
302
+ let headerTitle = typeof header?.title === "string" ? header.title : null;
303
+ let headerTs = toMs(header?.timestamp);
303
304
  let slotTitle: string | null = null;
304
305
  let initAgent: string | null = null;
305
306
  let initModel: string | null = null;
@@ -689,7 +690,7 @@ async function buildTrackTree(
689
690
  const childTrackId = trackId === "main" ? stem : `${trackId}/${stem}`;
690
691
  const childScan = await buildTrackTree(childFile, childTrackId, trackId, depth + 1, visited, out, scans);
691
692
  if (!childScan) continue;
692
- const isAdvisor = name === "__advisor.jsonl" || (name.startsWith("__advisor.") && name.endsWith(".jsonl"));
693
+ const isAdvisor = stem === "__advisor" || stem.startsWith("__advisor.");
693
694
  childScan.track.label = isAdvisor
694
695
  ? "Advisor"
695
696
  : headText(`${childScan.track.agent ?? "agent"} ${stem}`, LABEL_MAX);
@@ -697,6 +698,13 @@ async function buildTrackTree(
697
698
  }
698
699
  }
699
700
 
701
+ linkChildTracks(scan, childrenById);
702
+ return scan;
703
+ }
704
+
705
+ /** Place child activity and structured task-result spans on their parent track. */
706
+ function linkChildTracks(scan: TrackScan, childrenById: ReadonlyMap<string, TrackScan>): void {
707
+ const trackId = scan.track.id;
700
708
  // Subagent spans on this track, one per task SingleResult.
701
709
  const linkedChildren = new Set<string>();
702
710
  for (const result of scan.taskResults) {
@@ -746,8 +754,6 @@ async function buildTrackTree(
746
754
  scan.track.spans.push(span);
747
755
  }
748
756
  scan.track.spans.sort((a, b) => a.start - b.start || a.end - b.end);
749
-
750
- return scan;
751
757
  }
752
758
 
753
759
  // ---------------------------------------------------------------------------
@@ -863,6 +869,106 @@ export async function buildSessionTrace(fileParam: string, fingerprint?: TraceFi
863
869
  if (!rootScan) {
864
870
  throw Object.assign(new Error(`ENOENT: session not found: ${resolved}`), { code: "ENOENT" });
865
871
  }
872
+ // Re-collect the fingerprint AFTER the parse: a child append landing
873
+ // between the pre-build check and now would otherwise be baked into
874
+ // neither the trace nor the key.
875
+ const childFingerprint = await childTranscriptsFingerprint(resolved);
876
+ // Root rewritten mid-build: same no-cache rule as the child race below.
877
+ const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootMtimeMs;
878
+ const raced = childFingerprint !== preChildFingerprint;
879
+ // Raced builds keep the PRE-build fingerprint as their ETag: the parsed
880
+ // bytes predate the racy write, so claiming the post-write state would
881
+ // 304 stale forever when that write was the child's last. The pre-build
882
+ // key guarantees the next conditional poll misses (filesystem moved on)
883
+ // and rebuilds — at most one stale response per race, never an
884
+ // indefinitely cached one. Unraced builds use the post-build key, which
885
+ // equals the pre-build key by construction.
886
+ const etagFingerprint = raced || rootChanged ? preChildFingerprint : childFingerprint;
887
+ const trace = assembleSessionTrace(rootScan, tracks, scans, rootMtimeMs, `${rootMtimeMs}:${etagFingerprint}`);
888
+ if (!raced && !rootChanged) {
889
+ traceMemo = {
890
+ file: resolved,
891
+ entry: { mtimeMs: rootMtimeMs, childFingerprint, trace },
892
+ };
893
+ }
894
+ return trace;
895
+ }
896
+
897
+ /** Header metadata used by snapshot traces, including journals without a schema version. */
898
+ export interface TraceSnapshotHeader {
899
+ timestamp?: string;
900
+ cwd?: string;
901
+ title?: string;
902
+ }
903
+
904
+ /** The journal data for one embedded transcript; its header may be stored separately. */
905
+ export interface SessionTraceSnapshot {
906
+ header?: TraceSnapshotHeader | null;
907
+ entries: readonly SessionEntry[];
908
+ }
909
+
910
+ /** A collected child transcript, keyed by its slash-joined path from the root. */
911
+ export interface SubSessionTraceSnapshot extends SessionTraceSnapshot {
912
+ parent: string | null;
913
+ }
914
+
915
+ /**
916
+ * Assemble an offline trace using the same timing joins and rollups as the live
917
+ * filesystem builder. Only supplied snapshots participate: no filesystem, DB,
918
+ * network, or wall-clock reads. Track files are snapshot keys, never disk paths.
919
+ *
920
+ * Missing parent transcripts attach their readable descendants to main. Unlike
921
+ * filesystem discovery, already-collected snapshots need no depth limit.
922
+ * `mtimeMs` is the root's last activity (or header time, then 0); the constant
923
+ * `etag` identifies a snapshot, not a conditional HTTP response.
924
+ */
925
+ export function buildSessionTraceFromSnapshots(
926
+ root: SessionTraceSnapshot,
927
+ subSessions?: Readonly<Record<string, SubSessionTraceSnapshot>>,
928
+ ): SessionTrace {
929
+ const children = new Map<string | null, Array<[string, SubSessionTraceSnapshot]>>();
930
+ if (subSessions) {
931
+ for (const key of Object.keys(subSessions).sort()) {
932
+ const snapshot = subSessions[key];
933
+ const parent =
934
+ snapshot.parent !== null && Object.hasOwn(subSessions, snapshot.parent) ? snapshot.parent : null;
935
+ const siblings = children.get(parent);
936
+ if (siblings) siblings.push([key, snapshot]);
937
+ else children.set(parent, [[key, snapshot]]);
938
+ }
939
+ }
940
+
941
+ const tracks: TraceTrack[] = [];
942
+ const scans: TrackScan[] = [];
943
+ const visit = (snapshot: SessionTraceSnapshot, key: string, parentId: string | null): TrackScan => {
944
+ const scan = scanTranscript(snapshot.entries, key, parentId, key, snapshot.header);
945
+ tracks.push(scan.track);
946
+ scans.push(scan);
947
+ const childrenById = new Map<string, TrackScan>();
948
+ for (const [childKey, childSnapshot] of children.get(parentId === null ? null : key) ?? []) {
949
+ const child = visit(childSnapshot, childKey, key);
950
+ const stem = childKey.slice(childKey.lastIndexOf("/") + 1);
951
+ child.track.label =
952
+ stem === "__advisor" || stem.startsWith("__advisor.")
953
+ ? "Advisor"
954
+ : headText(`${child.track.agent ?? "agent"} ${stem}`, LABEL_MAX);
955
+ childrenById.set(stem, child);
956
+ }
957
+ linkChildTracks(scan, childrenById);
958
+ return scan;
959
+ };
960
+ const rootScan = visit(root, "main", null);
961
+ return assembleSessionTrace(rootScan, tracks, scans, rootScan.lastActivity ?? rootScan.headerTs ?? 0, "snapshot");
962
+ }
963
+
964
+ /** Shared aggregation after either filesystem discovery or snapshot traversal. */
965
+ function assembleSessionTrace(
966
+ rootScan: TrackScan,
967
+ tracks: TraceTrack[],
968
+ scans: TrackScan[],
969
+ rootMtimeMs: number,
970
+ etag: string,
971
+ ): SessionTrace {
866
972
  rootScan.track.label = "Main";
867
973
 
868
974
  let startedAt: number | undefined;
@@ -935,39 +1041,17 @@ export async function buildSessionTrace(fileParam: string, fingerprint?: TraceFi
935
1041
  toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
936
1042
  };
937
1043
 
938
- // Re-collect the fingerprint AFTER the parse: a child append landing
939
- // between the pre-build check and now would otherwise be baked into
940
- // neither the trace nor the key.
941
- const childFingerprint = await childTranscriptsFingerprint(resolved);
942
- // Root rewritten mid-build: same no-cache rule as the child race below.
943
- const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootMtimeMs;
944
- const raced = childFingerprint !== preChildFingerprint;
945
- // Raced builds keep the PRE-build fingerprint as their ETag: the parsed
946
- // bytes predate the racy write, so claiming the post-write state would
947
- // 304 stale forever when that write was the child's last. The pre-build
948
- // key guarantees the next conditional poll misses (filesystem moved on)
949
- // and rebuilds — at most one stale response per race, never an
950
- // indefinitely cached one. Unraced builds use the post-build key, which
951
- // equals the pre-build key by construction.
952
- const etagFingerprint = raced || rootChanged ? preChildFingerprint : childFingerprint;
953
- const trace: SessionTrace = {
954
- file: resolved,
1044
+ return {
1045
+ file: rootScan.track.file,
955
1046
  title: rootScan.title,
956
1047
  cwd: rootScan.cwd,
957
1048
  startedAt: start,
958
1049
  endedAt: end,
959
1050
  mtimeMs: rootMtimeMs,
960
- etag: `${rootMtimeMs}:${etagFingerprint}`,
1051
+ etag,
961
1052
  tracks,
962
1053
  summary,
963
1054
  };
964
- if (!raced && !rootChanged) {
965
- traceMemo = {
966
- file: resolved,
967
- entry: { mtimeMs: rootMtimeMs, childFingerprint, trace },
968
- };
969
- }
970
- return trace;
971
1055
  }
972
1056
 
973
1057
  /** Fetch one full journal entry by id from a transcript, for the span drawer. */
package/src/types.ts CHANGED
@@ -231,12 +231,17 @@ export interface ToolCallStats {
231
231
  callsInTurn: number;
232
232
  /** Serialized argument characters */
233
233
  argsChars: number;
234
+ /** Result text characters, when the same parse also read the call's `toolResult` */
235
+ resultChars?: number;
236
+ /** Result error flag, set together with `resultChars` */
237
+ isError?: boolean;
234
238
  }
235
239
 
236
240
  /**
237
241
  * Result linkage emitted when the parser sees a `toolResult` message entry.
238
242
  * Applied as an UPDATE on the persisted tool-call row — results can land in a
239
- * later incremental sync pass than the call that produced them.
243
+ * later incremental sync pass than the call that produced them (calls parsed
244
+ * in the same pass already carry the result, so their UPDATE is a no-op).
240
245
  */
241
246
  export interface ToolResultLink {
242
247
  sessionFile: string;