@raquezha/notrace 0.0.7 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @raquezha/notrace
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a2fc3cb: Implement machine-global observability dashboard and Mistral-style timeline parser.
8
+ - Storage migrated from `.notrace/` in the local working directory to a machine-wide `~/.notrace/` directory to prevent repository pollution and enable global insights.
9
+ - Dashboard updated with a new `Project` column for multi-repo tracking.
10
+ - Timeline parser overhauled to render LLM arrays, tool execution cards, and code blocks beautifully instead of raw JSON dumps.
11
+
12
+ ### Patch Changes
13
+
14
+ - 8f31379: fix(noheadroom): match lowercase footer casing
15
+ feat(notrace): add session export to HTML retrospective
16
+
3
17
  ## 0.0.7
4
18
 
5
19
  ### Patch Changes
@@ -22,8 +22,11 @@ function appendWorkLogEntry(taskDir, message) {
22
22
  }
23
23
  const before = lines.slice(0, nextSection);
24
24
  const after = lines.slice(nextSection);
25
+ while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
26
+ before.pop();
27
+ }
25
28
  before.push(entry);
26
- writeFileSync(workMd, `${before.join("\n")}\n${after.join("\n")}`);
29
+ writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`);
27
30
  }
28
31
  catch { }
29
32
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
2
2
  import * as path from "node:path";
3
+ import * as os from "node:os";
3
4
  import { execSync } from "node:child_process";
4
5
  import { getActiveAdapter } from "./adapters.js";
5
6
  import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
@@ -147,9 +148,10 @@ function toTaskInfo(context) {
147
148
  dir: context.taskDir,
148
149
  };
149
150
  }
150
- function createIndexEntry(record, cwd, htmlPath, recordPath) {
151
+ function createIndexEntry(record, htmlPath, recordPath) {
151
152
  return {
152
153
  sessionId: record.traceId,
154
+ repositoryName: record.repository.name,
153
155
  startedAt: record.session.startedAt,
154
156
  endedAt: record.session.endedAt,
155
157
  captureMode: record.captureMode,
@@ -157,8 +159,8 @@ function createIndexEntry(record, cwd, htmlPath, recordPath) {
157
159
  conditions: record.conditions,
158
160
  activity: record.activity,
159
161
  artifacts: {
160
- html: path.relative(cwd, htmlPath),
161
- record: path.relative(cwd, recordPath),
162
+ html: htmlPath,
163
+ record: recordPath,
162
164
  },
163
165
  };
164
166
  }
@@ -251,13 +253,13 @@ export default function (pi) {
251
253
  const endedAt = Date.now();
252
254
  const adapter = getActiveAdapter(ctx.cwd);
253
255
  const context = adapter.getContext(ctx.cwd);
254
- const notraceDir = path.resolve(ctx.cwd, ".notrace");
256
+ const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
255
257
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
256
258
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
257
259
  const repositoryName = path.basename(ctx.cwd);
258
260
  let branchName = null;
259
261
  try {
260
- branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
262
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
261
263
  }
262
264
  catch {
263
265
  // not a git repo or no commits yet
@@ -317,17 +319,43 @@ export default function (pi) {
317
319
  writePrivateFileAtomic(htmlPath, html);
318
320
  writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
319
321
  const indexPath = path.join(notraceDir, "index.json");
320
- const existing = readJsonFile(indexPath, { repositoryName, sessions: [] });
321
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
322
- if (!isGhostSession) {
323
- sessions.push(createIndexEntry(record, ctx.cwd, htmlPath, recordPath));
322
+ const lockPath = `${indexPath}.lock`;
323
+ let lockAcquired = false;
324
+ for (let i = 0; i < 20; i++) {
325
+ try {
326
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
327
+ lockAcquired = true;
328
+ break;
329
+ }
330
+ catch {
331
+ const t = Date.now();
332
+ while (Date.now() - t < 50) { } // busy wait 50ms
333
+ }
334
+ }
335
+ try {
336
+ const existing = readJsonFile(indexPath, { sessions: [] });
337
+ let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
338
+ if (!isGhostSession) {
339
+ sessions.push(createIndexEntry(record, htmlPath, recordPath));
340
+ }
341
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
342
+ writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
343
+ }
344
+ finally {
345
+ if (lockAcquired && existsSync(lockPath)) {
346
+ try {
347
+ import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath));
348
+ }
349
+ catch { }
350
+ }
324
351
  }
325
- writePrivateFileAtomic(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
326
- writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
327
352
  if (context) {
353
+ const displayPath = htmlPath.startsWith(os.homedir())
354
+ ? `~${htmlPath.slice(os.homedir().length)}`
355
+ : htmlPath;
328
356
  adapter.attach(context, {
329
- html: path.relative(ctx.cwd, htmlPath),
330
- record: path.relative(ctx.cwd, recordPath)
357
+ html: displayPath,
358
+ record: recordPath
331
359
  });
332
360
  }
333
361
  console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
@@ -250,7 +250,7 @@ function shell(title, body, script = "") {
250
250
  align-items: center;
251
251
  margin-top: 16px;
252
252
  }
253
- .pill, .workflow-pill, .sort-btn {
253
+ .pill, .workflow-pill, .sort-btn, .export-btn {
254
254
  display: inline-flex;
255
255
  align-items: center;
256
256
  gap: 6px;
@@ -263,7 +263,7 @@ function shell(title, body, script = "") {
263
263
  font-size: 0.86rem;
264
264
  font-weight: 600;
265
265
  }
266
- .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin: 24px 0; }
266
+ .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 16px; margin: 24px 0; }
267
267
  .metric-card {
268
268
  background: var(--panel-strong);
269
269
  border: 1px solid var(--border);
@@ -382,7 +382,42 @@ function shell(title, body, script = "") {
382
382
  .msg-role { font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
383
383
  .msg.user .msg-role { color: #8ec5ff; }
384
384
  .msg.assistant .msg-role { color: var(--accent); }
385
- .msg-content { padding: 14px; white-space: pre-wrap; word-break: break-word; }
385
+ .msg-content { padding: 14px; }
386
+ .chat-text {
387
+ white-space: pre-wrap;
388
+ word-break: break-word;
389
+ font-size: 0.95rem;
390
+ line-height: 1.6;
391
+ margin-bottom: 12px;
392
+ }
393
+ .chat-text:last-child { margin-bottom: 0; }
394
+ .chat-tool-use {
395
+ background: rgba(0,0,0,0.3);
396
+ border: 1px solid var(--border);
397
+ border-radius: 8px;
398
+ overflow: hidden;
399
+ margin-bottom: 12px;
400
+ }
401
+ .chat-tool-use:last-child { margin-bottom: 0; }
402
+ .chat-tool-header {
403
+ background: rgba(255,255,255,0.04);
404
+ padding: 8px 12px;
405
+ font-size: 0.8rem;
406
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
407
+ color: #8ec5ff;
408
+ border-bottom: 1px solid var(--border);
409
+ display: flex;
410
+ align-items: center;
411
+ gap: 8px;
412
+ }
413
+ .chat-tool-body {
414
+ padding: 12px;
415
+ margin: 0;
416
+ background: transparent;
417
+ border: none;
418
+ max-height: 400px;
419
+ overflow-y: auto;
420
+ }
386
421
  .footer-note {
387
422
  margin-top: 22px;
388
423
  color: var(--muted);
@@ -427,7 +462,15 @@ function shell(title, body, script = "") {
427
462
  color: var(--text);
428
463
  border-bottom-color: rgba(236,227,218,0.45);
429
464
  }
430
- @media (max-width: 760px) {
465
+ .export-btn {
466
+ cursor: pointer;
467
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
468
+ }
469
+ .export-btn:hover, .export-btn.copied {
470
+ color: var(--text);
471
+ border-color: rgba(216,132,98,0.45);
472
+ background: var(--accent-soft);
473
+ }
431
474
  .container { padding: 20px 14px 48px; }
432
475
  .hero { padding: 20px; }
433
476
  .hero-top { grid-template-columns: 1fr; }
@@ -441,8 +484,25 @@ function shell(title, body, script = "") {
441
484
  <body>${body}${script ? `<script>${script}</script>` : ""}</body>
442
485
  </html>`;
443
486
  }
444
- function copyButton(value, label) {
445
- return `<button class="copy-btn" type="button" data-copy-value="${escapeHtml(value)}" aria-label="Copy ${escapeHtml(label)}" title="Copy ${escapeHtml(label)}"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
487
+ function copyButton(value, label, className = "copy-btn") {
488
+ return `<button class="${escapeHtml(className)}" type="button" data-copy-value="${escapeHtml(value)}" aria-label="Copy ${escapeHtml(label)}" title="Copy ${escapeHtml(label)}"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
489
+ }
490
+ function exportButton(data) {
491
+ const payload = JSON.stringify({
492
+ kind: "notrace-export",
493
+ traceId: data.traceId,
494
+ repository: data.repository?.name,
495
+ branch: data.repository?.branch,
496
+ task: data.task,
497
+ metrics: data.activity?.totals,
498
+ summary: data.telemetry?.extensions?.noheadroom?.summary,
499
+ events: (data.events || []).filter((e) => e.type === "llm_completion").map((e) => ({
500
+ model: e.model,
501
+ input: e.inputPayload?.messages,
502
+ output: e.outputContent
503
+ }))
504
+ });
505
+ return `<button class="export-btn" type="button" data-copy-value="${escapeHtml(payload)}" title="Copy session data for LLM/Agent context"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg><span>Export</span></button>`;
446
506
  }
447
507
  function copyScript() {
448
508
  return `(() => {
@@ -480,10 +540,66 @@ function copyScript() {
480
540
  function renderJsonBlock(title, value) {
481
541
  return `<section class="block"><h4>${escapeHtml(title)}</h4><pre>${escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2))}</pre></section>`;
482
542
  }
543
+ function renderToolUseHtml(name, input) {
544
+ const parsedInput = typeof input === "string" ? (() => { try {
545
+ return JSON.parse(input);
546
+ }
547
+ catch {
548
+ return input;
549
+ } })() : input;
550
+ return `<div class="chat-tool-use"><div class="chat-tool-header"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg> ${escapeHtml(name)}</div><pre class="chat-tool-body">${escapeHtml(typeof parsedInput === 'string' ? parsedInput : JSON.stringify(parsedInput, null, 2))}</pre></div>`;
551
+ }
552
+ function renderToolResultHtml(id, content) {
553
+ return `<div class="chat-tool-use"><div class="chat-tool-header" style="color: var(--muted);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"></polyline><path d="M20 4v7a4 4 0 0 1-4 4H4"></path></svg> Tool Result: ${escapeHtml(id)}</div><pre class="chat-tool-body">${escapeHtml(typeof content === 'string' ? content : JSON.stringify(content, null, 2))}</pre></div>`;
554
+ }
555
+ function renderUniversalMessageContent(m) {
556
+ if (!m)
557
+ return "";
558
+ let html = "";
559
+ // 1. Handle string content or Anthropic/Pi blocks
560
+ if (typeof m.content === "string" && m.content.trim()) {
561
+ html += `<div class="chat-text">${escapeHtml(m.content)}</div>`;
562
+ }
563
+ else if (Array.isArray(m.content)) {
564
+ html += m.content.map((block) => {
565
+ if (!block)
566
+ return "";
567
+ if (block.type === "text")
568
+ return `<div class="chat-text">${escapeHtml(block.text)}</div>`;
569
+ if (block.type === "tool_use")
570
+ return renderToolUseHtml(block.name, block.input);
571
+ if (block.type === "tool_result")
572
+ return renderToolResultHtml(block.tool_use_id || "unknown", block.content);
573
+ return `<pre class="chat-tool-body">${escapeHtml(JSON.stringify(block, null, 2))}</pre>`;
574
+ }).join("");
575
+ }
576
+ else if (m.content && typeof m.content === "object") {
577
+ html += `<pre class="chat-tool-body">${escapeHtml(JSON.stringify(m.content, null, 2))}</pre>`;
578
+ }
579
+ // 2. Handle OpenAI/Codex tool_calls (attached to message, not in content)
580
+ if (Array.isArray(m.tool_calls)) {
581
+ html += m.tool_calls.map((tc) => {
582
+ if (tc.type === "function" && tc.function) {
583
+ return renderToolUseHtml(tc.function.name, tc.function.arguments);
584
+ }
585
+ return "";
586
+ }).join("");
587
+ }
588
+ // 3. Handle OpenAI legacy function_call
589
+ if (m.function_call) {
590
+ html += renderToolUseHtml(m.function_call.name, m.function_call.arguments);
591
+ }
592
+ // 4. Handle OpenAI tool result (message role is "tool")
593
+ if (m.role === "tool" && !html.includes("chat-tool-result")) {
594
+ // If it was just a string, it rendered above. Wrap it in a tool result block instead.
595
+ html = renderToolResultHtml(m.tool_call_id || m.name || "unknown", m.content);
596
+ }
597
+ return html || `<div class="empty">Empty message</div>`;
598
+ }
483
599
  function renderMessages(messages) {
484
600
  if (!messages?.length)
485
601
  return "";
486
- return `<section class="block"><h4>Input Messages</h4>${messages.map(m => `<div class="msg ${escapeHtml(m?.role || "unknown")}"><div class="msg-head"><span class="msg-role">${escapeHtml(m?.role || "unknown")}</span></div><div class="msg-content">${escapeHtml(m?.content ?? "")}</div></div>`).join("")}</section>`;
602
+ return `<section class="block"><h4>Input Messages</h4>${messages.map(m => `<div class="msg ${escapeHtml(m?.role || "unknown")}"><div class="msg-head"><span class="msg-role">${escapeHtml(m?.role || "unknown")}</span></div><div class="msg-content">${renderUniversalMessageContent(m)}</div></div>`).join("")}</section>`;
487
603
  }
488
604
  function eventBadgeClass(ev) {
489
605
  if (ev.type === "llm_completion")
@@ -505,15 +621,15 @@ function renderEventCard(ev) {
505
621
  if (ev.errorMessage) {
506
622
  sections.push(renderJsonBlock("Error Message", ev.errorMessage));
507
623
  }
508
- sections.push(renderJsonBlock("Output", ev.outputContent));
624
+ sections.push(`<section class="block"><h4>Output</h4><div class="msg-content">${renderUniversalMessageContent({ content: ev.outputContent })}</div></section>`);
509
625
  if (ev.usage)
510
626
  sections.push(renderJsonBlock("Usage", ev.usage));
511
627
  }
512
628
  else if (ev.type === "tool_start") {
513
- sections.push(renderJsonBlock("Arguments", ev.args));
629
+ sections.push(`<section class="block"><h4>Arguments</h4><div class="msg-content"><div class="chat-tool-use"><div class="chat-tool-header"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg> Execution Input</div><pre class="chat-tool-body">${escapeHtml(typeof ev.args === 'string' ? ev.args : JSON.stringify(ev.args, null, 2))}</pre></div></div></section>`);
514
630
  }
515
631
  else if (ev.type === "tool_end") {
516
- sections.push(renderJsonBlock(ev.isError ? "Error Result" : "Result", ev.result));
632
+ sections.push(`<section class="block"><h4>${ev.isError ? "Error Result" : "Result"}</h4><div class="msg-content"><div class="chat-tool-use" style="${ev.isError ? 'border-color: rgba(239,127,127,0.3);' : ''}"><div class="chat-tool-header" style="${ev.isError ? 'color: var(--err);' : 'color: var(--muted);'}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"></polyline><path d="M20 4v7a4 4 0 0 1-4 4H4"></path></svg> Execution Output</div><pre class="chat-tool-body">${escapeHtml(typeof ev.result === 'string' ? ev.result : JSON.stringify(ev.result, null, 2))}</pre></div></div></section>`);
517
633
  }
518
634
  else {
519
635
  sections.push(renderJsonBlock("Event", ev));
@@ -588,17 +704,19 @@ export function generateDashboardHtml(sessions, options = {}) {
588
704
  const reversed = sessions.slice().reverse();
589
705
  const totalCost = sessions.reduce((sum, s) => sum + Number(s.activity?.totals?.totalCostUsd || 0), 0);
590
706
  const totalTokens = sessions.reduce((sum, s) => sum + Number(s.activity?.totals?.totalTokens || 0), 0);
591
- const repositoryName = resolveRepoName(options);
592
707
  const homeHref = options?.indexHref || "index.html";
593
708
  const body = `<div class="container">
594
709
  <section class="hero">
595
- <div class="hero-top">
596
- <div>
597
- <div class="brand"><a class="brand-link" href="${escapeHtml(homeHref)}">${wordmarkSvg()}</a><p class="subtitle">Retrospective index. Repo-local session evidence.</p></div>
598
- </div>
599
- <div class="meta">
600
- <span class="pill">${escapeHtml(repositoryName)}</span>
601
- <span class="pill">${sessions.length} sessions</span>
710
+ <div class="hero-split">
711
+ <a class="brand-link" href="${escapeHtml(homeHref)}">${wordmarkSvg()}</a>
712
+ <div class="hero-right">
713
+ <div class="hero-session">
714
+ <strong style="color: var(--text); font-weight: 500;">Global Index</strong>
715
+ <span style="color: var(--muted);">Machine-wide session evidence.</span>
716
+ </div>
717
+ <div class="hero-meta">
718
+ <span class="hero-pill">${sessions.length} sessions</span>
719
+ </div>
602
720
  </div>
603
721
  </div>
604
722
  <div class="metrics">
@@ -609,14 +727,14 @@ export function generateDashboardHtml(sessions, options = {}) {
609
727
  </section>
610
728
  <section class="panel">
611
729
  <h2 class="section-title">Session Reports</h2>
612
- ${reversed.length ? `<table data-dashboard-table><thead><tr><th class="col-index sortable-head"><button class="sort-btn" data-sort-key="index"><span class="sort-label">#</span><span class="sort-state">↓</span></button></th><th>Session</th><th class="sortable-head"><button class="sort-btn" data-sort-key="workflow"><span class="sort-label">Workflow</span><span class="sort-state"></span></button></th><th class="sortable-head"><button class="sort-btn" data-sort-key="started"><span class="sort-label">Started</span><span class="sort-state"></span></button></th><th>Task</th><th class="sortable-head num-cell"><button class="sort-btn" data-sort-key="tokens"><span class="sort-label">Tokens</span><span class="sort-state"></span></button></th><th class="sortable-head num-cell"><button class="sort-btn" data-sort-key="cost"><span class="sort-label">Cost</span><span class="sort-state"></span></button></th></tr></thead><tbody>
730
+ ${reversed.length ? `<table data-dashboard-table><thead><tr><th class="col-index sortable-head"><button class="sort-btn" data-sort-key="index"><span class="sort-label">#</span><span class="sort-state">↓</span></button></th><th>Session</th><th>Project</th><th class="sortable-head"><button class="sort-btn" data-sort-key="workflow"><span class="sort-label">Workflow</span><span class="sort-state"></span></button></th><th class="sortable-head"><button class="sort-btn" data-sort-key="started"><span class="sort-label">Started</span><span class="sort-state"></span></button></th><th>Task</th><th class="sortable-head num-cell"><button class="sort-btn" data-sort-key="tokens"><span class="sort-label">Tokens</span><span class="sort-state"></span></button></th><th class="sortable-head num-cell"><button class="sort-btn" data-sort-key="cost"><span class="sort-label">Cost</span><span class="sort-state"></span></button></th></tr></thead><tbody>
613
731
  ${reversed.map((s, index) => {
614
- const link = s.artifacts.html.startsWith(".notrace/") ? s.artifacts.html.substring(9) : s.artifacts.html;
732
+ const link = s.artifacts?.html ? (s.artifacts.html.startsWith(".notrace/") ? s.artifacts.html.substring(9) : s.artifacts.html) : "#";
615
733
  const workflow = s.task?.workflow || "generic";
616
734
  const workflowLabel = workflowDisplayName(workflow);
617
735
  const tokens = Number(s.activity?.totals?.totalTokens || 0);
618
736
  const cost = Number(s.activity?.totals?.totalCostUsd || 0);
619
- return `<tr data-index="${reversed.length - index}" data-workflow="${escapeHtml(workflowLabel)}" data-started="${parseDate(s.startedAt)?.getTime() || 0}" data-tokens="${tokens}" data-cost="${cost}"><td class="index-cell">${reversed.length - index}</td><td><a class="session-link" href="${escapeHtml(link)}"><strong>${escapeHtml(String(s.sessionId).slice(0, 8))}</strong><span class="session-sub">${escapeHtml(String(s.sessionId))}</span></a></td><td><span class="workflow-pill ${workflowClassName(workflow)}">${escapeHtml(workflowLabel)}</span></td><td>${formatDateCell(s.startedAt)}</td><td>${escapeHtml(taskDisplay(s))}</td><td class="num-cell">${tokens.toLocaleString()}</td><td class="num-cell">${formatUsd(cost)}</td></tr>`;
737
+ return `<tr data-index="${reversed.length - index}" data-workflow="${escapeHtml(workflowLabel)}" data-started="${parseDate(s.startedAt)?.getTime() || 0}" data-tokens="${tokens}" data-cost="${cost}"><td class="index-cell">${reversed.length - index}</td><td><a class="session-link" href="${escapeHtml(link)}"><strong>${escapeHtml(String(s.sessionId).slice(0, 8))}</strong><span class="session-sub">${escapeHtml(String(s.sessionId))}</span></a></td><td><span class="hero-pill">${escapeHtml(s.repositoryName || "Unknown")}</span></td><td><span class="workflow-pill ${workflowClassName(workflow)}">${escapeHtml(workflowLabel)}</span></td><td>${formatDateCell(s.startedAt)}</td><td>${escapeHtml(taskDisplay(s))}</td><td class="num-cell">${tokens.toLocaleString()}</td><td class="num-cell">${formatUsd(cost)}</td></tr>`;
620
738
  }).join("")}
621
739
  </tbody></table>` : `<div class="empty">No sessions yet. Run Pi with notrace enabled. New reports appear here.</div>`}
622
740
  </section>
@@ -639,6 +757,7 @@ export function generateHtmlReport(data) {
639
757
  <span class="pill">${escapeHtml(resolveRepoName(data))}</span>
640
758
  <span class="pill">Started ${formatDateLong(data.session?.startedAt)}</span>
641
759
  <span class="pill">Mode: ${escapeHtml(data.captureMode || "full")}</span>
760
+ ${exportButton(data)}
642
761
  </div>
643
762
  </div>
644
763
  <div class="metrics">
@@ -30,8 +30,11 @@ function appendWorkLogEntry(taskDir: string, message: string): void {
30
30
  }
31
31
  const before = lines.slice(0, nextSection);
32
32
  const after = lines.slice(nextSection);
33
+ while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
34
+ before.pop();
35
+ }
33
36
  before.push(entry);
34
- writeFileSync(workMd, `${before.join("\n")}\n${after.join("\n")}`);
37
+ writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`);
35
38
  } catch { }
36
39
  }
37
40
 
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
3
3
  import * as path from "node:path";
4
+ import * as os from "node:os";
4
5
  import { execSync } from "node:child_process";
5
6
  import type {
6
7
  NotraceActivity,
@@ -185,9 +186,10 @@ function toTaskInfo(context: WorkflowContext | null): NotraceRunRecord["task"] {
185
186
  };
186
187
  }
187
188
 
188
- function createIndexEntry(record: NotraceRunRecord, cwd: string, htmlPath: string, recordPath: string): Record<string, unknown> {
189
+ function createIndexEntry(record: NotraceRunRecord, htmlPath: string, recordPath: string): Record<string, unknown> {
189
190
  return {
190
191
  sessionId: record.traceId,
192
+ repositoryName: record.repository.name,
191
193
  startedAt: record.session.startedAt,
192
194
  endedAt: record.session.endedAt,
193
195
  captureMode: record.captureMode,
@@ -195,8 +197,8 @@ function createIndexEntry(record: NotraceRunRecord, cwd: string, htmlPath: strin
195
197
  conditions: record.conditions,
196
198
  activity: record.activity,
197
199
  artifacts: {
198
- html: path.relative(cwd, htmlPath),
199
- record: path.relative(cwd, recordPath),
200
+ html: htmlPath,
201
+ record: recordPath,
200
202
  },
201
203
  };
202
204
  }
@@ -299,13 +301,13 @@ export default function (pi: ExtensionAPI) {
299
301
  const endedAt = Date.now();
300
302
  const adapter = getActiveAdapter(ctx.cwd);
301
303
  const context = adapter.getContext(ctx.cwd);
302
- const notraceDir = path.resolve(ctx.cwd, ".notrace");
304
+ const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
303
305
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
304
306
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
305
307
  const repositoryName = path.basename(ctx.cwd);
306
308
  let branchName: string | null = null;
307
309
  try {
308
- branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
310
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
309
311
  } catch {
310
312
  // not a git repo or no commits yet
311
313
  }
@@ -371,20 +373,41 @@ export default function (pi: ExtensionAPI) {
371
373
  writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
372
374
 
373
375
  const indexPath = path.join(notraceDir, "index.json");
374
- const existing = readJsonFile<any>(indexPath, { repositoryName, sessions: [] });
375
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
376
-
377
- if (!isGhostSession) {
378
- sessions.push(createIndexEntry(record, ctx.cwd, htmlPath, recordPath));
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
+ }
379
402
  }
380
-
381
- writePrivateFileAtomic(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
382
- writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
383
403
 
384
404
  if (context) {
405
+ const displayPath = htmlPath.startsWith(os.homedir())
406
+ ? `~${htmlPath.slice(os.homedir().length)}`
407
+ : htmlPath;
385
408
  adapter.attach(context, {
386
- html: path.relative(ctx.cwd, htmlPath),
387
- record: path.relative(ctx.cwd, recordPath)
409
+ html: displayPath,
410
+ record: recordPath
388
411
  });
389
412
  }
390
413