noctrace 1.2.0 → 1.3.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.
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-8{top:calc(var(--spacing) * 8)}.right-0{right:calc(var(--spacing) * 0)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.left-0{left:calc(var(--spacing) * 0)}.left-2{left:calc(var(--spacing) * 2)}.z-10{z-index:10}.z-50{z-index:50}.row-1{grid-row:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-full{height:100%}.w-full{width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.\[file\:line\]{file:line}@media (width>=48rem){.md\:hidden{display:none}}}:root{--ctp-base:#1e1e2e;--ctp-mantle:#181825;--ctp-crust:#11111b;--ctp-surface0:#313244;--ctp-surface1:#45475a;--ctp-surface2:#585b70;--ctp-overlay0:#6c7086;--ctp-overlay1:#7f849c;--ctp-text:#cdd6f4;--ctp-subtext0:#a6adc8;--ctp-subtext1:#bac2de;--ctp-blue:#89b4fa;--ctp-green:#a6e3a1;--ctp-yellow:#f9e2af;--ctp-peach:#fab387;--ctp-mauve:#cba6f7;--ctp-teal:#94e2d5;--ctp-red:#f38ba8;--ctp-pink:#f5c2e7;--ctp-lavender:#b4befe;--ctp-sky:#89dceb;--color-read:var(--ctp-blue);--color-write:var(--ctp-green);--color-edit:var(--ctp-yellow);--color-bash:var(--ctp-peach);--color-agent:var(--ctp-mauve);--color-grep:var(--ctp-teal);--color-error:var(--ctp-red);--color-running:var(--ctp-pink);--color-monitor:var(--ctp-sky)}body{background-color:var(--ctp-base);color:var(--ctp-text);font-family:SF Mono,Cascadia Code,JetBrains Mono,Fira Code,ui-monospace,monospace}@keyframes skeleton-shimmer{0%,to{opacity:.6}50%{opacity:.3}}@keyframes pulse-edge{0%,to{opacity:.6}50%{opacity:.1}}.running-pulse{animation:1.2s ease-in-out infinite pulse-edge}@keyframes noc-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.85)}}@keyframes noc-blink{0%,to{opacity:1}50%{opacity:0}}@media (width<=768px){.hidden-mobile{display:none!important}}.sidebar-panel{background-color:var(--ctp-mantle);flex-direction:column;flex-shrink:0;width:240px;display:flex;overflow:hidden}@media (width<=767px){.sidebar-panel{width:240px;transition:transform .2s;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%)}.sidebar-panel[data-open=true]{transform:translate(0)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-C0dmCjnv.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DYum71Dc.css">
10
+ <script type="module" crossorigin src="/assets/index-BGW0xA7n.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DlKrxvV-.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Cross-session Patterns rollup orchestrator.
3
- * Lists session JSONL files, applies mtime pre-filter, parses + caches each,
3
+ * Iterates registered providers, calls listSessions + readSession for each,
4
4
  * and folds results into a PatternsResponse.
5
+ *
6
+ * Phase B: uses the Provider registry instead of walking ~/.claude/projects/ directly.
5
7
  */
6
8
  import fs from 'node:fs/promises';
7
9
  import path from 'node:path';
8
10
  import os from 'node:os';
9
- import { parseJsonlContent } from '../shared/parser.js';
11
+ import { listProviders } from '../shared/providers/index.js';
12
+ import { createClaudeCodeProvider } from '../shared/providers/claude-code.js';
10
13
  import { buildSessionSummaryFromContent, } from '../shared/session-summary.js';
11
14
  // ---------------------------------------------------------------------------
12
15
  // Window math
@@ -112,56 +115,6 @@ function deslugify(slug, username) {
112
115
  }
113
116
  return '/' + parts.join('/');
114
117
  }
115
- /** List all *.jsonl session files under projectsDir with their mtime. */
116
- async function listSessionFiles(projectsDir) {
117
- const result = [];
118
- let slugs;
119
- try {
120
- slugs = await fs.readdir(projectsDir);
121
- }
122
- catch {
123
- // Directory doesn't exist — graceful degradation
124
- return result;
125
- }
126
- for (const slug of slugs) {
127
- const slugPath = path.join(projectsDir, slug);
128
- let slugStat;
129
- try {
130
- slugStat = await fs.stat(slugPath);
131
- }
132
- catch {
133
- continue;
134
- }
135
- if (!slugStat.isDirectory())
136
- continue;
137
- let files;
138
- try {
139
- files = await fs.readdir(slugPath);
140
- }
141
- catch {
142
- continue;
143
- }
144
- for (const file of files) {
145
- if (!file.endsWith('.jsonl'))
146
- continue;
147
- const filePath = path.join(slugPath, file);
148
- let fstat;
149
- try {
150
- fstat = await fs.stat(filePath);
151
- }
152
- catch {
153
- continue;
154
- }
155
- result.push({
156
- filePath,
157
- slug,
158
- sessionId: file.replace(/\.jsonl$/, ''),
159
- mtimeMs: fstat.mtimeMs,
160
- });
161
- }
162
- }
163
- return result;
164
- }
165
118
  // ---------------------------------------------------------------------------
166
119
  // Main export
167
120
  // ---------------------------------------------------------------------------
@@ -170,47 +123,88 @@ async function listSessionFiles(projectsDir) {
170
123
  *
171
124
  * @param window - 'today' | '7d' | '30d'
172
125
  * @param cache - mtime-invalidated summary cache
173
- * @param claudeHome - defaults to CLAUDE_HOME env or ~/.claude
126
+ * @param claudeHome - optional override; when provided a temporary claude-code
127
+ * provider is created for that home directory (used in tests)
174
128
  * @param now - injectable for tests; defaults to Date.now()
175
129
  */
176
130
  export async function computeRollup(window, cache, claudeHome, now) {
177
131
  const nowMs = now ?? Date.now();
178
132
  const home = claudeHome ?? (process.env['CLAUDE_HOME'] ?? path.join(os.homedir(), '.claude'));
179
- const projectsDir = path.join(home, 'projects');
180
133
  const username = os.userInfo().username;
181
134
  const bounds = computeWindow(window, nowMs);
182
135
  const { startMs, endMs, prevStartMs, prevEndMs } = bounds;
183
136
  const MS_PER_DAY = 86_400_000;
184
- // List all session files
185
- const allFiles = await listSessionFiles(projectsDir);
186
- // Pre-filter by mtime: include files whose mtime is within window + 1-day slack
187
- // This is a fast pre-filter; we still check session startMs after parsing.
188
- const preFilterStart = prevStartMs - MS_PER_DAY;
189
- const currentCandidates = allFiles.filter((f) => f.mtimeMs >= preFilterStart && f.mtimeMs <= endMs + MS_PER_DAY);
190
- // Parse + cache each file
137
+ // Use a provider scoped to the requested claudeHome when an override is provided
138
+ // (tests pass a tmpHome); otherwise use the global registry.
139
+ const providers = claudeHome
140
+ ? [createClaudeCodeProvider(claudeHome)]
141
+ : listProviders();
142
+ // Collect session records from all providers
143
+ // We use a wider window for listing so the pre-filter + previous window both work.
144
+ const listWindow = {
145
+ startMs: prevStartMs - MS_PER_DAY,
146
+ endMs: endMs + MS_PER_DAY,
147
+ };
148
+ const allRecords = [];
149
+ for (const provider of providers) {
150
+ let sessions;
151
+ try {
152
+ sessions = await provider.listSessions(listWindow);
153
+ }
154
+ catch {
155
+ // If a provider fails to list, skip it gracefully
156
+ continue;
157
+ }
158
+ for (const meta of sessions) {
159
+ // Derive the file path from rawSlug for Claude Code cache-key purposes.
160
+ // rawSlug format: '<projectSlug>/<sessionId>'
161
+ const slashIdx = meta.rawSlug.indexOf('/');
162
+ if (slashIdx === -1)
163
+ continue;
164
+ const slug = meta.rawSlug.slice(0, slashIdx);
165
+ const sessionId = meta.rawSlug.slice(slashIdx + 1);
166
+ const filePath = path.join(home, 'projects', slug, `${sessionId}.jsonl`);
167
+ const mtimeMs = meta.endMs ?? meta.startMs;
168
+ allRecords.push({
169
+ cacheKey: `${provider.id}:${meta.rawSlug}`,
170
+ filePath,
171
+ rawSlug: meta.rawSlug,
172
+ slug,
173
+ sessionId,
174
+ mtimeMs,
175
+ provider,
176
+ });
177
+ }
178
+ }
179
+ // Parse + cache each session
191
180
  const errors = [];
192
- const parsed = await runChunked(currentCandidates.map((sf) => async () => {
193
- const { summary, error } = await cache.getOrBuild(sf.filePath, async () => {
194
- let content;
181
+ const parsed = await runChunked(allRecords.map((sr) => async () => {
182
+ // Use the file path as cache key for mtime-based invalidation.
183
+ // The build function reads the file and delegates to the provider for parsing.
184
+ const { summary, error } = await cache.getOrBuild(sr.filePath, async () => {
185
+ // Read raw content for accurate compaction boundary counting.
186
+ let rawContent;
195
187
  try {
196
- content = await fs.readFile(sf.filePath, 'utf8');
188
+ rawContent = await fs.readFile(sr.filePath, 'utf8');
197
189
  }
198
190
  catch (err) {
199
191
  throw new Error(err instanceof Error ? err.message : String(err));
200
192
  }
201
- const rows = parseJsonlContent(content);
202
- return buildSessionSummaryFromContent(rows, sf.sessionId, sf.slug, content);
193
+ // Use the provider to parse into WaterfallRow[] for consistency.
194
+ const session = await sr.provider.readSession(sr.rawSlug);
195
+ const rows = session.native;
196
+ return buildSessionSummaryFromContent(rows, sr.sessionId, sr.slug, rawContent);
203
197
  });
204
- return { sf, summary, error };
198
+ return { sr, summary, error };
205
199
  }), 20);
206
200
  // Collect errors and filter out failed parses
207
201
  const summaries = [];
208
202
  for (const item of parsed) {
209
203
  if (item.error && item.summary === null) {
210
- errors.push({ path: item.sf.filePath, reason: item.error });
204
+ errors.push({ path: item.sr.filePath, reason: item.error });
211
205
  }
212
206
  else if (item.summary !== null) {
213
- summaries.push({ sf: item.sf, summary: item.summary });
207
+ summaries.push({ sr: item.sr, summary: item.summary });
214
208
  }
215
209
  }
216
210
  // Separate current vs previous window by session start time
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * REST API routes for project and session data.
3
- * All data is read from JSONL files on disk no in-memory caching.
3
+ * All data is read from JSONL files on disk via the Provider registry.
4
+ * Phase B: session reads are routed through getProvider() instead of calling
5
+ * parseJsonlContent directly. Raw file content is still read separately for
6
+ * enrichments not yet in the Provider interface (compaction, drift, tips, etc.).
4
7
  */
5
8
  import express, { Router } from 'express';
6
9
  import fs from 'node:fs/promises';
7
10
  import path from 'node:path';
8
11
  import { WebSocket } from 'ws';
9
- import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
12
+ import { parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
10
13
  import { parseSessionResultMetrics, parseInitContext } from '../../shared/session-metadata.js';
11
14
  import { computeContextHealth } from '../../shared/health.js';
12
15
  import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
13
16
  import { attachEfficiencyTips } from '../../shared/tips.js';
14
17
  import { attachSecurityTips } from '../../shared/security-tips.js';
15
18
  import { sessionToOtlp } from '../../shared/otlp-export.js';
19
+ import { createClaudeCodeProvider } from '../../shared/providers/claude-code.js';
16
20
  /**
17
21
  * Read ~/.claude/sessions/*.json and return a Set of sessionIds
18
22
  * whose PID is still a running claude process.
@@ -63,13 +67,19 @@ function assertWithinBase(resolved, base) {
63
67
  /**
64
68
  * Build the Express router, scoped to a given Claude home directory.
65
69
  * `wss` is the WebSocketServer instance used to broadcast hook events to
66
- * all connected browser clients.
70
+ * all connected browser clients. Optional in tests that only exercise
71
+ * non-WebSocket endpoints.
67
72
  */
68
73
  export function buildApiRouter(claudeHome, wss) {
69
74
  const router = Router();
70
75
  const projectsDir = path.join(claudeHome, 'projects');
71
76
  const teamsDir = path.join(claudeHome, 'teams');
72
77
  const tasksDir = path.join(claudeHome, 'tasks');
78
+ /**
79
+ * Provider scoped to this router's claudeHome.
80
+ * Used by session-read endpoints to route through the Provider abstraction.
81
+ */
82
+ const sessionProvider = createClaudeCodeProvider(claudeHome);
73
83
  /**
74
84
  * In-memory registry of MCP-registered session paths.
75
85
  * Populated by POST /api/sessions/register; cleared on unregister or server restart.
@@ -80,6 +90,8 @@ export function buildApiRouter(claudeHome, wss) {
80
90
  const dockerHeartbeats = new Map();
81
91
  /** Broadcast a message to all connected WebSocket clients. */
82
92
  function broadcast(msg) {
93
+ if (!wss)
94
+ return;
83
95
  const payload = JSON.stringify(msg);
84
96
  for (const client of wss.clients) {
85
97
  if (client.readyState === WebSocket.OPEN) {
@@ -420,15 +432,18 @@ export function buildApiRouter(claudeHome, wss) {
420
432
  return;
421
433
  }
422
434
  try {
435
+ // Use the provider to read session rows; fall back to 404 when not found.
436
+ let rows;
423
437
  let content;
424
438
  try {
425
439
  content = await fs.readFile(filePath, 'utf8');
440
+ const session = await sessionProvider.readSession(`${slug}/${id}`);
441
+ rows = session.native;
426
442
  }
427
443
  catch {
428
444
  res.status(404).json({ error: `Session not found: ${slug}/${id}` });
429
445
  return;
430
446
  }
431
- const rows = parseJsonlContent(content);
432
447
  const sessionId = extractSessionId(content) ?? id;
433
448
  const otlp = sessionToOtlp(rows, sessionId);
434
449
  res.setHeader('Content-Type', 'application/json');
@@ -457,15 +472,19 @@ export function buildApiRouter(claudeHome, wss) {
457
472
  return;
458
473
  }
459
474
  try {
475
+ // Use the provider to read session rows; fall back to 404 when not found.
476
+ let rows;
460
477
  let content;
461
478
  try {
479
+ // Read raw content for compaction/drift/tips analysis (not in Provider interface yet)
462
480
  content = await fs.readFile(filePath, 'utf8');
481
+ const session = await sessionProvider.readSession(`${slug}/${id}`);
482
+ rows = session.native;
463
483
  }
464
484
  catch {
465
485
  res.status(404).json({ error: `Session not found: ${slug}/${id}` });
466
486
  return;
467
487
  }
468
- const rows = parseJsonlContent(content);
469
488
  const boundaries = parseCompactionBoundaries(content);
470
489
  const health = computeContextHealth(rows, boundaries.length);
471
490
  const sessionId = extractSessionId(content) ?? id;
@@ -669,7 +688,7 @@ export function buildApiRouter(claudeHome, wss) {
669
688
  };
670
689
  // For SubagentStart events, broadcast a separate message type
671
690
  // so the client can show an in-progress agent row immediately
672
- if (event.hook_event_name === 'SubagentStart' && event.agent_id) {
691
+ if (event.hook_event_name === 'SubagentStart' && event.agent_id && wss) {
673
692
  const subagentMsg = {
674
693
  type: 'subagent-start',
675
694
  agentId: event.agent_id,
@@ -685,9 +704,11 @@ export function buildApiRouter(claudeHome, wss) {
685
704
  }
686
705
  const message = { type: 'hook-event', event };
687
706
  const payload = JSON.stringify(message);
688
- for (const client of wss.clients) {
689
- if (client.readyState === WebSocket.OPEN) {
690
- client.send(payload);
707
+ if (wss) {
708
+ for (const client of wss.clients) {
709
+ if (client.readyState === WebSocket.OPEN) {
710
+ client.send(payload);
711
+ }
691
712
  }
692
713
  }
693
714
  res.json({ ok: true });
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * WebSocket handler for real-time session event streaming.
3
3
  * Mounts at /ws. One watcher per connection, cleaned up on disconnect.
4
+ *
5
+ * Phase B: the top-level directory watcher that broadcasts session-created
6
+ * events is now driven by provider.watch() from the Provider registry.
7
+ * Per-connection file watchers (watchSession / watchSubAgent) remain as-is
8
+ * since they do incremental row parsing not yet part of the Provider interface.
4
9
  */
5
10
  import { WebSocketServer, WebSocket } from 'ws';
6
11
  import { spawn } from 'node:child_process';
@@ -9,6 +14,7 @@ import fs from 'node:fs';
9
14
  import path from 'node:path';
10
15
  import { watchSession, watchSubAgent } from './watcher.js';
11
16
  import { extractAgentIds } from '../shared/parser.js';
17
+ import { createClaudeCodeProvider } from '../shared/providers/claude-code.js';
12
18
  // ---------------------------------------------------------------------------
13
19
  // Helpers
14
20
  // ---------------------------------------------------------------------------
@@ -54,22 +60,16 @@ export function setupWebSocket(server, claudeHome) {
54
60
  });
55
61
  // Suppress unhandled WSS errors during port retry (EADDRINUSE propagates here)
56
62
  wss.on('error', () => { });
57
- // Watch the projects directory for new .jsonl session files.
58
- // When a new file appears, broadcast to all connected clients so they
59
- // can refresh their session list without a manual page reload.
60
- const projectsBase = path.join(claudeHome, 'projects');
61
- const dirWatcher = chokidar.watch(projectsBase, {
62
- persistent: true,
63
- ignoreInitial: true,
64
- depth: 1,
65
- });
66
- dirWatcher.on('add', (filePath) => {
67
- if (!filePath.endsWith('.jsonl'))
63
+ // Watch the projects directory via the provider's watch() interface.
64
+ // session-added events map to session-created WebSocket broadcasts.
65
+ const dirProvider = createClaudeCodeProvider(claudeHome);
66
+ const unsubscribeDir = dirProvider.watch((event) => {
67
+ if (event.kind !== 'session-added')
68
68
  return;
69
- // Derive the project slug from the parent directory name
70
- const relative = path.relative(projectsBase, filePath);
71
- const slug = path.dirname(relative);
72
- if (!slug || slug === '.')
69
+ // sessionId for claude-code is '<projectSlug>/<fileId>'
70
+ const slashIdx = event.sessionId.indexOf('/');
71
+ const slug = slashIdx !== -1 ? event.sessionId.slice(0, slashIdx) : event.sessionId;
72
+ if (!slug)
73
73
  return;
74
74
  const msg = { type: 'session-created', slug };
75
75
  const payload = JSON.stringify(msg);
@@ -79,11 +79,8 @@ export function setupWebSocket(server, claudeHome) {
79
79
  }
80
80
  }
81
81
  });
82
- dirWatcher.on('error', (err) => {
83
- console.warn('[noctrace] dir watcher error:', err instanceof Error ? err.message : String(err));
84
- });
85
82
  wss.on('close', () => {
86
- dirWatcher.close().catch(() => { });
83
+ unsubscribeDir();
87
84
  });
88
85
  wss.on('connection', (ws, _req) => {
89
86
  let stopWatcher = null;