@raquezha/notrace 0.1.1 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/notrace/index.d.ts +34 -0
  3. package/dist/notrace/index.js +144 -118
  4. package/dist/notrace/report-app/__tests__/analytics.test.d.ts +1 -0
  5. package/dist/notrace/report-app/__tests__/analytics.test.js +35 -0
  6. package/dist/notrace/report-app/__tests__/card.test.d.ts +1 -0
  7. package/dist/notrace/report-app/__tests__/card.test.js +26 -0
  8. package/dist/notrace/report-app/__tests__/event.test.d.ts +1 -0
  9. package/dist/notrace/report-app/__tests__/event.test.js +20 -0
  10. package/dist/notrace/report-app/__tests__/format.test.d.ts +1 -0
  11. package/dist/notrace/report-app/__tests__/format.test.js +41 -0
  12. package/dist/notrace/report-app/__tests__/report.test.d.ts +1 -0
  13. package/dist/notrace/report-app/__tests__/report.test.js +31 -0
  14. package/dist/notrace/report-app/analytics.d.ts +3 -0
  15. package/dist/notrace/report-app/analytics.js +78 -0
  16. package/dist/notrace/report-app/client.d.ts +2 -0
  17. package/dist/notrace/report-app/client.js +105 -0
  18. package/dist/notrace/report-app/components/card.d.ts +4 -0
  19. package/dist/notrace/report-app/components/card.js +36 -0
  20. package/dist/notrace/report-app/components/dashboard.d.ts +1 -0
  21. package/dist/notrace/report-app/components/dashboard.js +16 -0
  22. package/dist/notrace/report-app/components/event.d.ts +5 -0
  23. package/dist/notrace/report-app/components/event.js +42 -0
  24. package/dist/notrace/report-app/components/message.d.ts +2 -0
  25. package/dist/notrace/report-app/components/message.js +43 -0
  26. package/dist/notrace/report-app/dashboard-report.d.ts +1 -0
  27. package/dist/notrace/report-app/dashboard-report.js +6 -0
  28. package/dist/notrace/report-app/escape.d.ts +1 -0
  29. package/dist/notrace/report-app/escape.js +10 -0
  30. package/dist/notrace/report-app/format.d.ts +13 -0
  31. package/dist/notrace/report-app/format.js +102 -0
  32. package/dist/notrace/report-app/report.d.ts +1 -0
  33. package/dist/notrace/report-app/report.js +29 -0
  34. package/dist/notrace/report-app/shell.d.ts +5 -0
  35. package/dist/notrace/report-app/shell.js +19 -0
  36. package/dist/notrace/report-app/styles.d.ts +1 -0
  37. package/dist/notrace/report-app/styles.js +431 -0
  38. package/dist/notrace/report-app/types.d.ts +28 -0
  39. package/dist/notrace/report-app/types.js +1 -0
  40. package/extensions/notrace/__tests__/ghost-session.test.ts +103 -0
  41. package/extensions/notrace/__tests__/helpers.ts +11 -0
  42. package/extensions/notrace/__tests__/lock-race.test.ts +176 -0
  43. package/extensions/notrace/__tests__/usage-normalization.test.ts +80 -0
  44. package/extensions/notrace/index.ts +160 -124
  45. package/extensions/notrace/report-app/__tests__/analytics.test.ts +41 -0
  46. package/extensions/notrace/report-app/__tests__/card.test.ts +29 -0
  47. package/extensions/notrace/report-app/__tests__/event.test.ts +23 -0
  48. package/extensions/notrace/report-app/__tests__/format.test.ts +46 -0
  49. package/extensions/notrace/report-app/__tests__/report.test.ts +33 -0
  50. package/extensions/notrace/report-app/analytics.ts +79 -0
  51. package/extensions/notrace/report-app/client.ts +106 -0
  52. package/extensions/notrace/report-app/components/card.ts +38 -0
  53. package/extensions/notrace/report-app/components/dashboard.ts +17 -0
  54. package/extensions/notrace/report-app/components/event.ts +39 -0
  55. package/extensions/notrace/report-app/components/message.ts +39 -0
  56. package/extensions/notrace/report-app/dashboard-report.ts +7 -0
  57. package/extensions/notrace/report-app/escape.ts +10 -0
  58. package/extensions/notrace/report-app/format.ts +107 -0
  59. package/extensions/notrace/report-app/report.ts +33 -0
  60. package/extensions/notrace/report-app/shell.ts +24 -0
  61. package/extensions/notrace/report-app/styles.ts +431 -0
  62. package/extensions/notrace/report-app/types.ts +35 -0
  63. package/package.json +4 -2
  64. package/templates/dashboard.sample.html +103 -63
  65. package/templates/dashboard.sample.json +73 -10
  66. package/templates/render-samples.mjs +119 -1
  67. package/templates/session.sample.html +125 -168
  68. package/templates/session.sample.json +66 -7
  69. package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.html +125 -163
  70. package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.json +50 -0
  71. package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.html +125 -162
  72. package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.json +50 -0
  73. package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +125 -163
  74. package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.json +50 -0
  75. package/templates/sessions/019ed2ee-massive/notrace.html +498 -0
  76. package/templates/sessions/019ed2ee-massive/notrace.json +14660 -0
  77. package/tsconfig.json +1 -1
  78. package/dist/notrace/renderer.d.ts +0 -4
  79. package/dist/notrace/renderer.js +0 -800
  80. package/extensions/notrace/renderer.ts +0 -810
@@ -0,0 +1,176 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { handleSessionShutdown, type SessionShutdownDeps } from "../index.js";
7
+ import { cleanupTempNotraceDir, makeTempNotraceDir } from "./helpers.js";
8
+
9
+ const tempDirs: string[] = [];
10
+
11
+ function makeDeps(notraceDir: string, traceId: string): SessionShutdownDeps {
12
+ return {
13
+ events: [{ type: "tool_start", toolName: "test-tool", args: {}, timestamp: Date.now() }],
14
+ startTime: Date.now() - 1000,
15
+ traceId,
16
+ extensionTelemetry: new Map(),
17
+ captureMode: "full",
18
+ notraceDir,
19
+ adapter: {
20
+ name: "test",
21
+ detect: () => true,
22
+ getContext: () => null,
23
+ attach: () => {},
24
+ },
25
+ };
26
+ }
27
+
28
+ function makeCtx(sessionId: string) {
29
+ return {
30
+ cwd: process.cwd(),
31
+ sessionManager: {
32
+ getSessionId: () => sessionId,
33
+ },
34
+ };
35
+ }
36
+
37
+ function readJson(filePath: string) {
38
+ return JSON.parse(readFileSync(filePath, "utf-8"));
39
+ }
40
+
41
+ function sessionDir(notraceDir: string, sessionId: string): string {
42
+ return path.join(notraceDir, "sessions", sessionId.replace(/[^a-z0-9]/gi, "-"));
43
+ }
44
+
45
+ async function runWorker(sessionId: string, notraceDir: string): Promise<void> {
46
+ const distIndexPath = path.resolve(process.cwd(), "dist/notrace/index.js");
47
+ const workerScript = `
48
+ import { parentPort, workerData } from "node:worker_threads";
49
+ const { handleSessionShutdown } = await import(workerData.distIndexPath);
50
+ const deps = {
51
+ events: [{ type: "tool_start", toolName: "test-tool", args: {}, timestamp: Date.now() }],
52
+ startTime: Date.now() - 1000,
53
+ traceId: workerData.sessionId,
54
+ extensionTelemetry: new Map(),
55
+ captureMode: "full",
56
+ notraceDir: workerData.notraceDir,
57
+ adapter: { name: "test", detect: () => true, getContext: () => null, attach: () => {} },
58
+ };
59
+ const ctx = {
60
+ cwd: workerData.cwd,
61
+ sessionManager: { getSessionId: () => workerData.sessionId },
62
+ };
63
+ await handleSessionShutdown({ reason: "worker-test" }, ctx, deps);
64
+ parentPort.postMessage("done");
65
+ `;
66
+
67
+ await new Promise<void>((resolve, reject) => {
68
+ const worker = new Worker(workerScript, {
69
+ eval: true,
70
+ type: "module",
71
+ workerData: { sessionId, notraceDir, cwd: process.cwd(), distIndexPath },
72
+ });
73
+ worker.once("message", () => resolve());
74
+ worker.once("error", reject);
75
+ worker.once("exit", (code) => {
76
+ if (code !== 0) reject(new Error(`worker exited with code ${code}`));
77
+ });
78
+ });
79
+ }
80
+
81
+ if (!isMainThread) {
82
+ const { sessionId, notraceDir } = workerData as { sessionId: string; notraceDir: string };
83
+ await handleSessionShutdown({ reason: "worker-test" }, makeCtx(sessionId), makeDeps(notraceDir, sessionId));
84
+ parentPort?.postMessage("done");
85
+ }
86
+
87
+ if (isMainThread) {
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ while (tempDirs.length) cleanupTempNotraceDir(tempDirs.pop()!);
91
+ });
92
+
93
+ describe("handleSessionShutdown lock behavior", () => {
94
+ it("keeps both index entries from two concurrent shutdowns", async () => {
95
+ const notraceDir = makeTempNotraceDir();
96
+ tempDirs.push(notraceDir);
97
+ execSync("npm run build", { cwd: process.cwd(), stdio: "ignore" });
98
+
99
+ await Promise.all([
100
+ runWorker("session-a", notraceDir),
101
+ runWorker("session-b", notraceDir),
102
+ ]);
103
+
104
+ const index = readJson(path.join(notraceDir, "index.json"));
105
+ const sessionIds = index.sessions.map((session: { sessionId: string }) => session.sessionId).sort();
106
+
107
+ expect(sessionIds).toEqual(["session-a", "session-b"]);
108
+ });
109
+
110
+ it("warns and skips index update when lock acquisition fails", async () => {
111
+ const notraceDir = makeTempNotraceDir();
112
+ tempDirs.push(notraceDir);
113
+
114
+ const indexPath = path.join(notraceDir, "index.json");
115
+ const lockPath = `${indexPath}.lock`;
116
+ const seed = { sessions: [{ sessionId: "existing-session" }] };
117
+ writeFileSync(indexPath, `${JSON.stringify(seed, null, 2)}\n`);
118
+ writeFileSync(lockPath, "held");
119
+
120
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
121
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
122
+
123
+ await expect(handleSessionShutdown(
124
+ { reason: "lock-held" },
125
+ makeCtx("skipped-session"),
126
+ makeDeps(notraceDir, "skipped-session"),
127
+ )).resolves.toBeUndefined();
128
+
129
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not acquire index lock"));
130
+ expect(readJson(indexPath)).toEqual(seed);
131
+ expect(existsSync(path.join(sessionDir(notraceDir, "skipped-session"), "notrace.json"))).toBe(true);
132
+ expect(existsSync(path.join(sessionDir(notraceDir, "skipped-session"), "notrace.html"))).toBe(true);
133
+ expect(existsSync(lockPath)).toBe(true);
134
+ expect(log).toHaveBeenCalled();
135
+ });
136
+
137
+ it("removes the lock file after a successful shutdown", async () => {
138
+ const notraceDir = makeTempNotraceDir();
139
+ tempDirs.push(notraceDir);
140
+
141
+ const lockPath = path.join(notraceDir, "index.json.lock");
142
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
143
+
144
+ await handleSessionShutdown(
145
+ { reason: "success" },
146
+ makeCtx("lock-cleanup-session"),
147
+ makeDeps(notraceDir, "lock-cleanup-session"),
148
+ );
149
+
150
+ expect(existsSync(lockPath)).toBe(false);
151
+ expect(log).toHaveBeenCalled();
152
+ });
153
+
154
+ it("writes a correct index entry for a normal shutdown", async () => {
155
+ const notraceDir = makeTempNotraceDir();
156
+ tempDirs.push(notraceDir);
157
+ const sessionId = "single-session";
158
+
159
+ mkdirSync(notraceDir, { recursive: true });
160
+ vi.spyOn(console, "log").mockImplementation(() => {});
161
+
162
+ await handleSessionShutdown(
163
+ { reason: "normal" },
164
+ makeCtx(sessionId),
165
+ makeDeps(notraceDir, sessionId),
166
+ );
167
+
168
+ const index = readJson(path.join(notraceDir, "index.json"));
169
+ expect(index.sessions).toHaveLength(1);
170
+ expect(index.sessions[0].sessionId).toBe(sessionId);
171
+ expect(index.sessions[0].repositoryName).toBe(path.basename(process.cwd()));
172
+ expect(existsSync(index.sessions[0].artifacts.html)).toBe(true);
173
+ expect(existsSync(index.sessions[0].artifacts.record)).toBe(true);
174
+ });
175
+ });
176
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeUsage } from "../index.js";
3
+
4
+ describe("normalizeUsage", () => {
5
+ it("uses explicit totalTokens as-is", () => {
6
+ expect(normalizeUsage({
7
+ inputTokens: 10,
8
+ outputTokens: 20,
9
+ cacheReadTokens: 30,
10
+ cacheWriteTokens: 40,
11
+ totalTokens: 7,
12
+ })).toMatchObject({
13
+ inputTokens: 10,
14
+ outputTokens: 20,
15
+ cacheReadTokens: 30,
16
+ cacheWriteTokens: 40,
17
+ totalTokens: 7,
18
+ });
19
+ });
20
+
21
+ it("sums component fields when totalTokens is missing", () => {
22
+ expect(normalizeUsage({
23
+ inputTokens: 10,
24
+ outputTokens: 20,
25
+ cacheReadTokens: 3,
26
+ cacheWriteTokens: 4,
27
+ })).toMatchObject({
28
+ inputTokens: 10,
29
+ outputTokens: 20,
30
+ cacheReadTokens: 3,
31
+ cacheWriteTokens: 4,
32
+ totalTokens: 37,
33
+ });
34
+ });
35
+
36
+ it("normalizes empty or null usage to zero", () => {
37
+ expect(normalizeUsage({})).toMatchObject({
38
+ inputTokens: 0,
39
+ outputTokens: 0,
40
+ cacheReadTokens: 0,
41
+ cacheWriteTokens: 0,
42
+ totalTokens: 0,
43
+ totalCostUsd: 0,
44
+ });
45
+ expect(normalizeUsage(null)).toMatchObject({
46
+ inputTokens: 0,
47
+ outputTokens: 0,
48
+ cacheReadTokens: 0,
49
+ cacheWriteTokens: 0,
50
+ totalTokens: 0,
51
+ totalCostUsd: 0,
52
+ });
53
+ });
54
+
55
+ it("supports mixed field name variants when totalTokens is absent", () => {
56
+ expect(normalizeUsage({
57
+ input: 5,
58
+ outputTokens: 6,
59
+ cacheRead: 7,
60
+ cacheWriteTokens: 8,
61
+ })).toMatchObject({
62
+ inputTokens: 5,
63
+ outputTokens: 6,
64
+ cacheReadTokens: 7,
65
+ cacheWriteTokens: 8,
66
+ totalTokens: 26,
67
+ });
68
+ });
69
+
70
+ it("keeps totalCostUsd normalization unchanged", () => {
71
+ expect(normalizeUsage({
72
+ inputTokens: 1,
73
+ outputTokens: 2,
74
+ cost: { total: 0.123 },
75
+ })).toMatchObject({
76
+ totalTokens: 3,
77
+ totalCostUsd: 0.123,
78
+ });
79
+ });
80
+ });
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync, rmSync } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as os from "node:os";
5
5
  import { execSync } from "node:child_process";
@@ -12,8 +12,9 @@ import type {
12
12
  NotraceRunRecord,
13
13
  WorkflowContext,
14
14
  } from "./types.js";
15
- import { getActiveAdapter } from "./adapters.js";
16
- import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
15
+ import { getActiveAdapter, type WorkflowAdapter } from "./adapters.js";
16
+ import { generateHtmlReport } from "./report-app/report.js";
17
+ import { generateDashboardHtml } from "./report-app/dashboard-report.js";
17
18
 
18
19
  const REDACTED = "[REDACTED by notrace]";
19
20
  const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|AKIA[0-9A-Z]{16})/gi;
@@ -81,14 +82,18 @@ function asNumber(value: unknown): number {
81
82
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
82
83
  }
83
84
 
84
- function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
85
+ export function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & { totalCostUsd: number } {
85
86
  const usage = (raw && typeof raw === "object" ? raw : {}) as UsageLike;
87
+ const inputTokens = asNumber(usage.inputTokens ?? usage.input);
88
+ const outputTokens = asNumber(usage.outputTokens ?? usage.output);
89
+ const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
90
+ const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
86
91
  return {
87
- inputTokens: asNumber(usage.inputTokens ?? usage.input),
88
- outputTokens: asNumber(usage.outputTokens ?? usage.output),
89
- cacheReadTokens: asNumber(usage.cacheReadTokens ?? usage.cacheRead),
90
- cacheWriteTokens: asNumber(usage.cacheWriteTokens ?? usage.cacheWrite),
91
- totalTokens: asNumber(usage.totalTokens),
92
+ inputTokens,
93
+ outputTokens,
94
+ cacheReadTokens,
95
+ cacheWriteTokens,
96
+ totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
92
97
  totalCostUsd: asNumber(usage.cost?.total),
93
98
  };
94
99
  }
@@ -203,6 +208,144 @@ function createIndexEntry(record: NotraceRunRecord, htmlPath: string, recordPath
203
208
  };
204
209
  }
205
210
 
211
+ export type SessionShutdownDeps = {
212
+ events: NotraceEvent[];
213
+ startTime: number;
214
+ traceId: string;
215
+ extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
216
+ captureMode: NotraceCaptureMode;
217
+ notraceDir: string;
218
+ adapter: WorkflowAdapter;
219
+ };
220
+
221
+ export async function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void> {
222
+ const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
223
+ const endedAt = Date.now();
224
+ const context = deps.adapter.getContext(ctx.cwd);
225
+ const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
226
+ const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
227
+ const repositoryName = path.basename(ctx.cwd);
228
+ let branchName: string | null = null;
229
+ try {
230
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
231
+ } catch {
232
+ // not a git repo or no commits yet
233
+ }
234
+ const recordPath = path.join(outputDir, "notrace.json");
235
+
236
+ let mergedEvents = deps.events;
237
+ let originalStartedAt = deps.startTime;
238
+ let originalTask: any = null;
239
+ if (existsSync(recordPath)) {
240
+ try {
241
+ const oldRecord = readJsonFile<any>(recordPath, null);
242
+ if (Array.isArray(oldRecord.events)) {
243
+ mergedEvents = [...oldRecord.events, ...deps.events];
244
+ }
245
+ if (oldRecord.session?.startedAt) {
246
+ originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
247
+ }
248
+ if (oldRecord.task) {
249
+ originalTask = oldRecord.task;
250
+ }
251
+ } catch (err) {
252
+ // ignore parse errors
253
+ }
254
+ }
255
+
256
+ const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
257
+
258
+ // Do not index purely empty ghost sessions
259
+ const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
260
+
261
+ const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
262
+
263
+ const record: NotraceRunRecord = {
264
+ kind: "notrace-run",
265
+ schemaVersion: SCHEMA_VERSION,
266
+ traceId: finalTraceId,
267
+ repository: {
268
+ name: repositoryName,
269
+ cwd: ctx.cwd,
270
+ branch: branchName,
271
+ },
272
+ session: {
273
+ id: finalTraceId,
274
+ startedAt: new Date(originalStartedAt).toISOString(),
275
+ endedAt: new Date(endedAt).toISOString(),
276
+ durationMs: activity.durationMs,
277
+ shutdownReason,
278
+ },
279
+ task: toTaskInfo(context) || originalTask,
280
+ captureMode: deps.captureMode,
281
+ conditions: buildConditions(mergedEvents, telemetry),
282
+ activity,
283
+ telemetry: { extensions: telemetry },
284
+ events: mergedEvents,
285
+ };
286
+
287
+ validateRunRecord(record);
288
+ const htmlPath = path.join(outputDir, "notrace.html");
289
+
290
+ if (!isGhostSession) {
291
+ const html = generateHtmlReport(record);
292
+ mkdirSync(outputDir, { recursive: true });
293
+ writePrivateFileAtomic(htmlPath, html);
294
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
295
+ }
296
+
297
+ const indexPath = path.join(deps.notraceDir, "index.json");
298
+ const lockPath = `${indexPath}.lock`;
299
+ let lockAcquired = false;
300
+ for (let i = 0; i < 20; i++) {
301
+ try {
302
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
303
+ lockAcquired = true;
304
+ break;
305
+ } catch {
306
+ const t = Date.now(); while (Date.now() - t < 50) {} // busy wait 50ms
307
+ }
308
+ }
309
+
310
+ if (!lockAcquired) {
311
+ // Could not get exclusive access to the index after retrying. Skip the
312
+ // index/dashboard update rather than racing another process's
313
+ // read-modify-write on index.json. The per-session record and HTML
314
+ // report were already written above and are not affected.
315
+ console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
316
+ } else {
317
+ try {
318
+ const existing = readJsonFile<any>(indexPath, { sessions: [] });
319
+ let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
320
+
321
+ if (!isGhostSession) {
322
+ sessions.push(createIndexEntry(record, htmlPath, recordPath));
323
+ }
324
+
325
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
326
+ writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
327
+ } finally {
328
+ if (existsSync(lockPath)) {
329
+ try { rmSync(lockPath); } catch {}
330
+ }
331
+ }
332
+ }
333
+
334
+ if (context && !isGhostSession) {
335
+ const displayPath = htmlPath.startsWith(os.homedir())
336
+ ? `~${htmlPath.slice(os.homedir().length)}`
337
+ : htmlPath;
338
+ deps.adapter.attach(context, {
339
+ html: displayPath,
340
+ record: recordPath
341
+ });
342
+ }
343
+
344
+ if (!isGhostSession) {
345
+ console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
346
+ }
347
+ }
348
+
206
349
  function normalizeTelemetryPayload(raw: unknown): { extension: string; telemetry: NotraceExtensionTelemetry } | null {
207
350
  if (!raw || typeof raw !== "object") return null;
208
351
  const payload = raw as ExtensionTelemetryPayload;
@@ -235,7 +378,6 @@ export default function (pi: ExtensionAPI) {
235
378
  const startTime = Date.now();
236
379
  let traceId = "";
237
380
  let activeLlmPayload: unknown = null;
238
- let shutdownReason: string | null = null;
239
381
  const extensionTelemetry = new Map<string, NotraceExtensionTelemetry>();
240
382
  currentMode = getInitialMode();
241
383
 
@@ -297,120 +439,14 @@ export default function (pi: ExtensionAPI) {
297
439
  });
298
440
 
299
441
  pi.on("session_shutdown" as any, async (e: any, ctx: any) => {
300
- shutdownReason = typeof e?.reason === "string" ? e.reason : null;
301
- const endedAt = Date.now();
302
- const adapter = getActiveAdapter(ctx.cwd);
303
- const context = adapter.getContext(ctx.cwd);
304
- const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
305
- const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
306
- const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
307
- const repositoryName = path.basename(ctx.cwd);
308
- let branchName: string | null = null;
309
- try {
310
- branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
311
- } catch {
312
- // not a git repo or no commits yet
313
- }
314
- const recordPath = path.join(outputDir, "notrace.json");
315
-
316
- let mergedEvents = events;
317
- let originalStartedAt = startTime;
318
- let originalTask: any = null;
319
- if (existsSync(recordPath)) {
320
- try {
321
- const oldRecord = readJsonFile<any>(recordPath, null);
322
- if (Array.isArray(oldRecord.events)) {
323
- mergedEvents = [...oldRecord.events, ...events];
324
- }
325
- if (oldRecord.session?.startedAt) {
326
- originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
327
- }
328
- if (oldRecord.task) {
329
- originalTask = oldRecord.task;
330
- }
331
- } catch (err) {
332
- // ignore parse errors
333
- }
334
- }
335
-
336
- const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
337
-
338
- // Do not index purely empty ghost sessions
339
- const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
340
-
341
- const telemetry = Object.fromEntries([...extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
342
-
343
- const record: NotraceRunRecord = {
344
- kind: "notrace-run",
345
- schemaVersion: SCHEMA_VERSION,
346
- traceId: finalTraceId,
347
- repository: {
348
- name: repositoryName,
349
- cwd: ctx.cwd,
350
- branch: branchName,
351
- },
352
- session: {
353
- id: finalTraceId,
354
- startedAt: new Date(originalStartedAt).toISOString(),
355
- endedAt: new Date(endedAt).toISOString(),
356
- durationMs: activity.durationMs,
357
- shutdownReason,
358
- },
359
- task: toTaskInfo(context) || originalTask,
442
+ await handleSessionShutdown(e, ctx, {
443
+ events,
444
+ startTime,
445
+ traceId,
446
+ extensionTelemetry,
360
447
  captureMode: currentMode,
361
- conditions: buildConditions(mergedEvents, telemetry),
362
- activity,
363
- telemetry: { extensions: telemetry },
364
- events: mergedEvents,
365
- };
366
-
367
- validateRunRecord(record);
368
- const html = generateHtmlReport(record);
369
-
370
- mkdirSync(outputDir, { recursive: true });
371
- const htmlPath = path.join(outputDir, "notrace.html");
372
- writePrivateFileAtomic(htmlPath, html);
373
- writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
374
-
375
- const indexPath = path.join(notraceDir, "index.json");
376
- const lockPath = `${indexPath}.lock`;
377
- let lockAcquired = false;
378
- for (let i = 0; i < 20; i++) {
379
- try {
380
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
381
- lockAcquired = true;
382
- break;
383
- } catch {
384
- const t = Date.now(); while (Date.now() - t < 50) {} // busy wait 50ms
385
- }
386
- }
387
-
388
- try {
389
- const existing = readJsonFile<any>(indexPath, { sessions: [] });
390
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
391
-
392
- if (!isGhostSession) {
393
- sessions.push(createIndexEntry(record, htmlPath, recordPath));
394
- }
395
-
396
- writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
397
- writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
398
- } finally {
399
- if (lockAcquired && existsSync(lockPath)) {
400
- try { import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath)); } catch {}
401
- }
402
- }
403
-
404
- if (context) {
405
- const displayPath = htmlPath.startsWith(os.homedir())
406
- ? `~${htmlPath.slice(os.homedir().length)}`
407
- : htmlPath;
408
- adapter.attach(context, {
409
- html: displayPath,
410
- record: recordPath
411
- });
412
- }
413
-
414
- console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
448
+ notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
449
+ adapter: getActiveAdapter(ctx.cwd),
450
+ });
415
451
  });
416
452
  }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildModelSummary, buildModelSwitches, groupByModel } from "../analytics.js";
3
+
4
+ const events = [
5
+ { type: "llm_completion", model: "a", provider: "x", timestamp: 1000, usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 1 } } },
6
+ { type: "llm_completion", model: "a", provider: "x", timestamp: "2026-06-17T17:00:02.000Z", usage: { input: 20, output: 10, totalTokens: 30, cost: { total: 2 } }, errorMessage: "oops" },
7
+ { type: "llm_completion", model: "b", provider: "y", timestamp: "2026-06-17T17:00:03.000Z", usage: { input: 40, output: 20, cost: { total: 3 } } }, // missing totalTokens
8
+ ];
9
+
10
+ describe("report-app analytics", () => {
11
+ it("groups model usage and falls back when totalTokens is missing", () => {
12
+ const grouped = groupByModel(events as any);
13
+ expect(grouped.a.count).toBe(2);
14
+ expect(grouped.a.inputTokens).toBe(30);
15
+ expect(grouped.a.errors).toBe(1);
16
+
17
+ // b is missing totalTokens, should fallback to input + output (40 + 20)
18
+ expect(grouped.b.totalTokens).toBe(60);
19
+ });
20
+
21
+ it("tracks model switches and normalizes string timestamps", () => {
22
+ const switches = buildModelSwitches(events as any);
23
+ expect(switches).toHaveLength(1);
24
+ expect(switches[0].from).toBe("a");
25
+ expect(switches[0].to).toBe("b");
26
+ expect(switches[0].providerChanged).toBe(true);
27
+
28
+ // timestamp Delta between "2026-06-17T17:00:02.000Z" and "2026-06-17T17:00:03.000Z" is 1000ms
29
+ expect(switches[0].timeDelta).toBe(1000);
30
+ // falls back to input + output when totalTokens is missing
31
+ expect(switches[0].tokens).toBe(60);
32
+ });
33
+
34
+ it("builds summary", () => {
35
+ const summary = buildModelSummary(events as any);
36
+ expect(summary.firstModel).toBe("a");
37
+ expect(summary.finalModel).toBe("b");
38
+ expect(summary.switchCount).toBe(1);
39
+ expect(summary.uniqueModels).toBe(2);
40
+ });
41
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderCollapsibleSection, renderKeyValueList, renderToolResultHtml, renderToolUseHtml } from "../components/card.js";
3
+
4
+ describe("report-app card", () => {
5
+ it("renders tool use and result cards", () => {
6
+ const useHtml = renderToolUseHtml("bash", { command: "echo hi" });
7
+ const resultHtml = renderToolResultHtml("tool-1", { stdout: "ok" }, true);
8
+ expect(useHtml).toContain("chat-tool-use");
9
+ expect(useHtml).toContain("bash");
10
+ expect(resultHtml).toContain("Tool Result: tool-1");
11
+ expect(resultHtml).toContain("color: var(--err);");
12
+ });
13
+
14
+ it("escapes injected content", () => {
15
+ const html = renderToolUseHtml("<script>alert(1)</script>", "<script>alert(1)</script>");
16
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
17
+ expect(html).not.toContain("<script>alert(1)</script>");
18
+ });
19
+
20
+ it("renders collapsible sections and key-value lists", () => {
21
+ const section = renderCollapsibleSection("Models", "<div>body</div>", true);
22
+ const kv = renderKeyValueList([["A", "B"], ["Empty", ""]]);
23
+ expect(section).toContain("panel collapsible");
24
+ expect(section).toContain(" open");
25
+ expect(kv).toContain("kv-list");
26
+ expect(kv).toContain("Empty");
27
+ expect(kv).toContain(">-<");
28
+ });
29
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderEventCard } from "../components/event.js";
3
+
4
+ describe("report-app event", () => {
5
+ it("escapes hostile content in lazy event body attributes", () => {
6
+ const hostileEvent = {
7
+ type: "llm_completion",
8
+ outputContent: '<img src=x onerror=alert(1)><script>alert(1)</script>" autofocus onfocus=alert(1)',
9
+ timestamp: 1000
10
+ };
11
+
12
+ const html = renderEventCard(hostileEvent);
13
+
14
+ // The data-lazy-event-body attribute should contain the URI-encoded then HTML-escaped string.
15
+ // It must NOT contain the raw dangerous strings.
16
+ expect(html).not.toContain("<script>alert(1)</script>");
17
+ expect(html).not.toContain('onerror=alert(1)');
18
+ expect(html).toContain("data-lazy-event-body=");
19
+ // Double check that the encoded payload still holds the data safely.
20
+ expect(html).toContain("alert(1)"); // uri encoded form might just be alert(1) but the < > and " will be %3C %3E %22
21
+ expect(html).toContain("%26lt%3Bscript%26gt%3Balert(1)%26lt%3B%2Fscript%26gt%3B");
22
+ });
23
+ });