@raquezha/notrace 0.0.6 → 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.
@@ -83,7 +83,9 @@ function taskDisplay(taskish) {
83
83
  return "No active task";
84
84
  }
85
85
  function resolveRepoName(data) {
86
- return data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
86
+ const name = data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
87
+ const branch = data?.repository?.branch;
88
+ return branch ? `${name} @ ${branch}` : name;
87
89
  }
88
90
  function formatUsd(value) {
89
91
  const num = Number(value || 0);
@@ -202,6 +204,44 @@ function shell(title, body, script = "") {
202
204
  overflow: visible;
203
205
  }
204
206
  .subtitle { margin: 10px 0 0; color: var(--muted); }
207
+ .session-subtitle {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 10px;
211
+ flex-wrap: wrap;
212
+ }
213
+ .session-id-chip {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ max-width: 100%;
218
+ padding: 6px 8px 6px 10px;
219
+ border: 1px solid var(--border);
220
+ border-radius: 999px;
221
+ background: rgba(0,0,0,0.18);
222
+ color: var(--text);
223
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
224
+ font-size: 0.78rem;
225
+ word-break: break-all;
226
+ }
227
+ .copy-btn {
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ width: 26px;
232
+ height: 26px;
233
+ border: 1px solid rgba(255,255,255,0.12);
234
+ border-radius: 999px;
235
+ background: rgba(255,255,255,0.04);
236
+ color: var(--muted);
237
+ cursor: pointer;
238
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
239
+ }
240
+ .copy-btn:hover, .copy-btn.copied {
241
+ color: var(--text);
242
+ border-color: rgba(216,132,98,0.45);
243
+ background: var(--accent-soft);
244
+ }
205
245
  .meta {
206
246
  display: flex;
207
247
  gap: 8px;
@@ -210,7 +250,7 @@ function shell(title, body, script = "") {
210
250
  align-items: center;
211
251
  margin-top: 16px;
212
252
  }
213
- .pill, .workflow-pill, .sort-btn {
253
+ .pill, .workflow-pill, .sort-btn, .export-btn {
214
254
  display: inline-flex;
215
255
  align-items: center;
216
256
  gap: 6px;
@@ -223,7 +263,7 @@ function shell(title, body, script = "") {
223
263
  font-size: 0.86rem;
224
264
  font-weight: 600;
225
265
  }
226
- .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; }
227
267
  .metric-card {
228
268
  background: var(--panel-strong);
229
269
  border: 1px solid var(--border);
@@ -342,7 +382,42 @@ function shell(title, body, script = "") {
342
382
  .msg-role { font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
343
383
  .msg.user .msg-role { color: #8ec5ff; }
344
384
  .msg.assistant .msg-role { color: var(--accent); }
345
- .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
+ }
346
421
  .footer-note {
347
422
  margin-top: 22px;
348
423
  color: var(--muted);
@@ -387,7 +462,15 @@ function shell(title, body, script = "") {
387
462
  color: var(--text);
388
463
  border-bottom-color: rgba(236,227,218,0.45);
389
464
  }
390
- @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
+ }
391
474
  .container { padding: 20px 14px 48px; }
392
475
  .hero { padding: 20px; }
393
476
  .hero-top { grid-template-columns: 1fr; }
@@ -401,13 +484,122 @@ function shell(title, body, script = "") {
401
484
  <body>${body}${script ? `<script>${script}</script>` : ""}</body>
402
485
  </html>`;
403
486
  }
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>`;
506
+ }
507
+ function copyScript() {
508
+ return `(() => {
509
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
510
+ button.addEventListener('click', async () => {
511
+ const value = button.getAttribute('data-copy-value') || '';
512
+ try {
513
+ if (navigator.clipboard?.writeText) {
514
+ await navigator.clipboard.writeText(value);
515
+ } else {
516
+ const textarea = document.createElement('textarea');
517
+ textarea.value = value;
518
+ textarea.style.position = 'fixed';
519
+ textarea.style.opacity = '0';
520
+ document.body.appendChild(textarea);
521
+ textarea.focus();
522
+ textarea.select();
523
+ document.execCommand('copy');
524
+ textarea.remove();
525
+ }
526
+ const previous = button.innerHTML;
527
+ button.classList.add('copied');
528
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
529
+ setTimeout(() => {
530
+ button.classList.remove('copied');
531
+ button.innerHTML = previous;
532
+ }, 1400);
533
+ } catch {
534
+ button.textContent = 'ERR';
535
+ }
536
+ });
537
+ });
538
+ })();`;
539
+ }
404
540
  function renderJsonBlock(title, value) {
405
541
  return `<section class="block"><h4>${escapeHtml(title)}</h4><pre>${escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2))}</pre></section>`;
406
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
+ }
407
599
  function renderMessages(messages) {
408
600
  if (!messages?.length)
409
601
  return "";
410
- 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>`;
411
603
  }
412
604
  function eventBadgeClass(ev) {
413
605
  if (ev.type === "llm_completion")
@@ -429,15 +621,15 @@ function renderEventCard(ev) {
429
621
  if (ev.errorMessage) {
430
622
  sections.push(renderJsonBlock("Error Message", ev.errorMessage));
431
623
  }
432
- 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>`);
433
625
  if (ev.usage)
434
626
  sections.push(renderJsonBlock("Usage", ev.usage));
435
627
  }
436
628
  else if (ev.type === "tool_start") {
437
- 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>`);
438
630
  }
439
631
  else if (ev.type === "tool_end") {
440
- 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>`);
441
633
  }
442
634
  else {
443
635
  sections.push(renderJsonBlock("Event", ev));
@@ -512,17 +704,19 @@ export function generateDashboardHtml(sessions, options = {}) {
512
704
  const reversed = sessions.slice().reverse();
513
705
  const totalCost = sessions.reduce((sum, s) => sum + Number(s.activity?.totals?.totalCostUsd || 0), 0);
514
706
  const totalTokens = sessions.reduce((sum, s) => sum + Number(s.activity?.totals?.totalTokens || 0), 0);
515
- const repositoryName = resolveRepoName(options);
516
707
  const homeHref = options?.indexHref || "index.html";
517
708
  const body = `<div class="container">
518
709
  <section class="hero">
519
- <div class="hero-top">
520
- <div>
521
- <div class="brand"><a class="brand-link" href="${escapeHtml(homeHref)}">${wordmarkSvg()}</a><p class="subtitle">Retrospective index. Repo-local session evidence.</p></div>
522
- </div>
523
- <div class="meta">
524
- <span class="pill">${escapeHtml(repositoryName)}</span>
525
- <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>
526
720
  </div>
527
721
  </div>
528
722
  <div class="metrics">
@@ -533,14 +727,14 @@ export function generateDashboardHtml(sessions, options = {}) {
533
727
  </section>
534
728
  <section class="panel">
535
729
  <h2 class="section-title">Session Reports</h2>
536
- ${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>
537
731
  ${reversed.map((s, index) => {
538
- 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) : "#";
539
733
  const workflow = s.task?.workflow || "generic";
540
734
  const workflowLabel = workflowDisplayName(workflow);
541
735
  const tokens = Number(s.activity?.totals?.totalTokens || 0);
542
736
  const cost = Number(s.activity?.totals?.totalCostUsd || 0);
543
- 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>`;
544
738
  }).join("")}
545
739
  </tbody></table>` : `<div class="empty">No sessions yet. Run Pi with notrace enabled. New reports appear here.</div>`}
546
740
  </section>
@@ -557,12 +751,13 @@ export function generateHtmlReport(data) {
557
751
  <section class="hero">
558
752
  <div class="hero-top">
559
753
  <div>
560
- <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle">Session retrospective for ${escapeHtml(data.traceId)}</p></div>
754
+ <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>${escapeHtml(data.traceId)}</span>${copyButton(String(data.traceId || ""), "session ID")}</span></p></div>
561
755
  </div>
562
756
  <div class="meta">
563
- <span class="pill">${escapeHtml(repositoryName)}</span>
757
+ <span class="pill">${escapeHtml(resolveRepoName(data))}</span>
564
758
  <span class="pill">Started ${formatDateLong(data.session?.startedAt)}</span>
565
- <span class="pill">Capture ${escapeHtml(data.captureMode || "full")}</span>
759
+ <span class="pill">Mode: ${escapeHtml(data.captureMode || "full")}</span>
760
+ ${exportButton(data)}
566
761
  </div>
567
762
  </div>
568
763
  <div class="metrics">
@@ -601,5 +796,5 @@ export function generateHtmlReport(data) {
601
796
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
602
797
  </footer>
603
798
  </div>`;
604
- return shell(`notrace - ${data.traceId}`, body);
799
+ return shell(`notrace - ${data.traceId}`, body, copyScript());
605
800
  }
@@ -30,6 +30,7 @@ export type NotraceHarnessInfo = {
30
30
  export type NotraceRepositoryInfo = {
31
31
  name: string;
32
32
  cwd: string;
33
+ branch?: string | null;
33
34
  };
34
35
  export type NotraceSessionInfo = {
35
36
  id: string;
@@ -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,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
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";
5
+ import { execSync } from "node:child_process";
4
6
  import type {
5
7
  NotraceActivity,
6
8
  NotraceCaptureMode,
@@ -64,6 +66,7 @@ function isSensitiveKey(key: string): boolean {
64
66
  }
65
67
 
66
68
  function sanitizeTraceValue(value: unknown): unknown {
69
+ if (currentMode === "metadata") return { omitted: true, reason: "metadata-capture" };
67
70
  if (currentMode === "full") return value;
68
71
  if (value == null || typeof value !== "object") {
69
72
  return typeof value === "string" ? value.replace(SENSITIVE_VALUE_RE, REDACTED).slice(0, 10000) : value;
@@ -90,6 +93,30 @@ function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" |
90
93
  };
91
94
  }
92
95
 
96
+ function readJsonFile<T>(filePath: string, fallback: T): T {
97
+ try {
98
+ return JSON.parse(readFileSync(filePath, "utf-8")) as T;
99
+ } catch {
100
+ return fallback;
101
+ }
102
+ }
103
+
104
+ function writePrivateFileAtomic(filePath: string, content: string): void {
105
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
106
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
107
+ chmodSync(tmpPath, 0o600);
108
+ renameSync(tmpPath, filePath);
109
+ }
110
+
111
+ function validateRunRecord(record: NotraceRunRecord): void {
112
+ if (record.kind !== "notrace-run") throw new Error("notrace record validation failed: invalid kind");
113
+ if (record.schemaVersion !== SCHEMA_VERSION) throw new Error("notrace record validation failed: invalid schemaVersion");
114
+ if (!record.traceId || !record.session?.id) throw new Error("notrace record validation failed: missing session id");
115
+ if (!record.repository?.cwd) throw new Error("notrace record validation failed: missing repository cwd");
116
+ if (!record.activity?.totals) throw new Error("notrace record validation failed: missing activity totals");
117
+ if (!Array.isArray(record.events)) throw new Error("notrace record validation failed: events must be an array");
118
+ }
119
+
93
120
  function collectActivity(events: NotraceEvent[], startedAt: number, endedAt: number): NotraceActivity {
94
121
  const activity: NotraceActivity = {
95
122
  turnCount: 0,
@@ -159,9 +186,10 @@ function toTaskInfo(context: WorkflowContext | null): NotraceRunRecord["task"] {
159
186
  };
160
187
  }
161
188
 
162
- 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> {
163
190
  return {
164
191
  sessionId: record.traceId,
192
+ repositoryName: record.repository.name,
165
193
  startedAt: record.session.startedAt,
166
194
  endedAt: record.session.endedAt,
167
195
  captureMode: record.captureMode,
@@ -169,8 +197,8 @@ function createIndexEntry(record: NotraceRunRecord, cwd: string, htmlPath: strin
169
197
  conditions: record.conditions,
170
198
  activity: record.activity,
171
199
  artifacts: {
172
- html: path.relative(cwd, htmlPath),
173
- record: path.relative(cwd, recordPath),
200
+ html: htmlPath,
201
+ record: recordPath,
174
202
  },
175
203
  };
176
204
  }
@@ -273,10 +301,16 @@ export default function (pi: ExtensionAPI) {
273
301
  const endedAt = Date.now();
274
302
  const adapter = getActiveAdapter(ctx.cwd);
275
303
  const context = adapter.getContext(ctx.cwd);
276
- const notraceDir = path.resolve(ctx.cwd, ".notrace");
304
+ const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
277
305
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
278
306
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
279
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
+ }
280
314
  const recordPath = path.join(outputDir, "notrace.json");
281
315
 
282
316
  let mergedEvents = events;
@@ -284,7 +318,7 @@ export default function (pi: ExtensionAPI) {
284
318
  let originalTask: any = null;
285
319
  if (existsSync(recordPath)) {
286
320
  try {
287
- const oldRecord = JSON.parse(readFileSync(recordPath, "utf-8"));
321
+ const oldRecord = readJsonFile<any>(recordPath, null);
288
322
  if (Array.isArray(oldRecord.events)) {
289
323
  mergedEvents = [...oldRecord.events, ...events];
290
324
  }
@@ -313,6 +347,7 @@ export default function (pi: ExtensionAPI) {
313
347
  repository: {
314
348
  name: repositoryName,
315
349
  cwd: ctx.cwd,
350
+ branch: branchName,
316
351
  },
317
352
  session: {
318
353
  id: finalTraceId,
@@ -329,28 +364,50 @@ export default function (pi: ExtensionAPI) {
329
364
  events: mergedEvents,
330
365
  };
331
366
 
367
+ validateRunRecord(record);
332
368
  const html = generateHtmlReport(record);
333
369
 
334
370
  mkdirSync(outputDir, { recursive: true });
335
371
  const htmlPath = path.join(outputDir, "notrace.html");
336
- writeFileSync(htmlPath, html);
337
- writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
372
+ writePrivateFileAtomic(htmlPath, html);
373
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
338
374
 
339
375
  const indexPath = path.join(notraceDir, "index.json");
340
- const existing = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, "utf-8")) : { repositoryName, sessions: [] };
341
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
342
-
343
- if (!isGhostSession) {
344
- 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
+ }
345
402
  }
346
-
347
- writeFileSync(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
348
- writeFileSync(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
349
403
 
350
404
  if (context) {
405
+ const displayPath = htmlPath.startsWith(os.homedir())
406
+ ? `~${htmlPath.slice(os.homedir().length)}`
407
+ : htmlPath;
351
408
  adapter.attach(context, {
352
- html: path.relative(ctx.cwd, htmlPath),
353
- record: path.relative(ctx.cwd, recordPath)
409
+ html: displayPath,
410
+ record: recordPath
354
411
  });
355
412
  }
356
413