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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/client/assets/index-BGW0xA7n.js +30 -0
- package/dist/client/assets/index-DlKrxvV-.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/rollup.js +64 -70
- package/dist/server/server/routes/api.js +30 -9
- package/dist/server/server/ws.js +16 -19
- package/dist/server/shared/providers/claude-code.js +268 -0
- package/dist/server/shared/providers/index.js +37 -0
- package/dist/server/shared/providers/provider.js +5 -0
- package/dist/server/shared/session.js +6 -0
- package/package.json +3 -1
- package/dist/client/assets/index-C0dmCjnv.js +0 -30
- package/dist/client/assets/index-DYum71Dc.css +0 -2
|
@@ -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}
|
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
*
|
|
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 {
|
|
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 -
|
|
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
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
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(
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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 {
|
|
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.
|
|
204
|
+
errors.push({ path: item.sr.filePath, reason: item.error });
|
|
211
205
|
}
|
|
212
206
|
else if (item.summary !== null) {
|
|
213
|
-
summaries.push({
|
|
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
|
|
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 {
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
client.
|
|
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 });
|
package/dist/server/server/ws.js
CHANGED
|
@@ -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
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
-
//
|
|
70
|
-
const
|
|
71
|
-
const slug =
|
|
72
|
-
if (!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
|
-
|
|
83
|
+
unsubscribeDir();
|
|
87
84
|
});
|
|
88
85
|
wss.on('connection', (ws, _req) => {
|
|
89
86
|
let stopWatcher = null;
|