noctrace 0.5.1 → 0.6.1
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/README.md +13 -1
- package/bin/claude-config.js +139 -0
- package/bin/noctrace-mcp.js +249 -31
- package/bin/noctrace.js +31 -0
- package/bin/postinstall.js +52 -0
- package/bin/preuninstall.js +32 -0
- package/dist/client/assets/index-C4vi082v.css +2 -0
- package/dist/client/assets/index-Cm74Eldg.js +30 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +112 -0
- package/dist/server/shared/filter.js +172 -0
- package/dist/server/shared/latency-stats.js +76 -0
- package/dist/server/shared/session-compare.js +89 -0
- package/dist/server/shared/tips.js +36 -7
- package/package.json +4 -2
- package/dist/client/assets/index-CfD3iTQS.js +0 -30
- package/dist/client/assets/index-nE1IMGzA.css +0 -2
package/README.md
CHANGED
|
@@ -54,7 +54,10 @@ Requires Node.js 20+. That's it. No config required. Optional hooks for real-tim
|
|
|
54
54
|
- **Token drift detection** — tracks how per-turn token cost drifts from baseline, warns when sessions burn excessive quota
|
|
55
55
|
- **Context Health grade** — A-F letter grade from 5 signals with actionable recommendations
|
|
56
56
|
- **Compact stats pill** — toolbar shows agent count, health grade, drift factor, total tokens, and session duration at a glance
|
|
57
|
-
- **
|
|
57
|
+
- **Advanced filtering** — structured filter syntax: `type:bash`, `>5s`, `<100ms`, `tokens:>1k`, `error`, `running`, `success` — combinable with plain text search
|
|
58
|
+
- **Per-tool latency stats** — Session Stats flyout shows P50/P95/Max latency per tool type; calls exceeding a configurable threshold (default 5s) are flagged with a clock icon
|
|
59
|
+
- **Loop detection** — flags 3+ consecutive identical tool calls (same tool + same input) as a warning tip on the row
|
|
60
|
+
- **Session comparison** — split-screen view comparing two sessions side-by-side: health grades, summary metrics, tool mix bars, and context fill trajectory sparklines
|
|
58
61
|
- **Virtual scrolling** — handles sessions with thousands of tool calls
|
|
59
62
|
- **Zoom & pan** — mouse wheel zoom (1-50x), click-drag pan
|
|
60
63
|
- **Detail panel** — click any row for full tool input/output, resizable
|
|
@@ -66,6 +69,7 @@ Requires Node.js 20+. That's it. No config required. Optional hooks for real-tim
|
|
|
66
69
|
- **Session export** — share sessions as standalone offline HTML files
|
|
67
70
|
- **Hooks integration** — optional real-time event streaming from Claude Code
|
|
68
71
|
- **Context Drift Rate** — detect accelerating token growth before context rot hits
|
|
72
|
+
- **MCP session registry** — when integrated with Claude Code, sessions self-register on start and unregister on exit; dashboard shows only active sessions with a live count indicator
|
|
69
73
|
|
|
70
74
|

|
|
71
75
|
|
|
@@ -95,6 +99,14 @@ Click any row to inspect the full tool input and output. Two-column layout shows
|
|
|
95
99
|
|
|
96
100
|
## How it works
|
|
97
101
|
|
|
102
|
+
Noctrace has two modes depending on how you start it:
|
|
103
|
+
|
|
104
|
+
**Standalone mode** (`npx noctrace`) — scans all of `~/.claude/projects/` and shows every session. Good for reviewing past work or running alongside a session you've already started.
|
|
105
|
+
|
|
106
|
+
**MCP mode** (via Claude Code integration) — sessions register and unregister themselves automatically. The session picker shows only currently active sessions and a "MCP mode — N active sessions" indicator. Multiple Claude Code sessions share one noctrace dashboard without interference.
|
|
107
|
+
|
|
108
|
+
In both modes:
|
|
109
|
+
|
|
98
110
|
1. Starts a local server on `http://localhost:4117` (auto-finds next available port)
|
|
99
111
|
2. Opens your browser
|
|
100
112
|
3. Reads JSONL session logs from `~/.claude/projects/`
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for reading/writing Claude Code MCP server registration
|
|
4
|
+
* in ~/.claude/settings.json.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
* - noctrace --enable / --disable
|
|
8
|
+
* - bin/postinstall.js
|
|
9
|
+
* - bin/preuninstall.js
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the path to ~/.claude/settings.json.
|
|
17
|
+
* Respects the CLAUDE_HOME env var override.
|
|
18
|
+
*/
|
|
19
|
+
export function settingsPath() {
|
|
20
|
+
const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
21
|
+
return path.join(claudeHome, 'settings.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the ~/.claude directory path.
|
|
26
|
+
* Respects the CLAUDE_HOME env var override.
|
|
27
|
+
*/
|
|
28
|
+
export function claudeDir() {
|
|
29
|
+
return process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read and parse settings.json.
|
|
34
|
+
* Returns {} if the file doesn't exist or contains invalid JSON.
|
|
35
|
+
*/
|
|
36
|
+
export async function readSettings(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
// Corrupted JSON — start fresh rather than aborting
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return {};
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write settings back to disk, creating parent directories as needed.
|
|
53
|
+
*/
|
|
54
|
+
export async function writeSettings(filePath, settings) {
|
|
55
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
56
|
+
await fs.writeFile(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register noctrace as an MCP server in ~/.claude/settings.json.
|
|
61
|
+
*
|
|
62
|
+
* Adds:
|
|
63
|
+
* mcpServers.noctrace = { command: "npx", args: ["-y", "noctrace", "--mcp"] }
|
|
64
|
+
*
|
|
65
|
+
* Preserves all other settings and MCP server entries.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* @param {boolean} [opts.silent] Suppress console output (for postinstall quiet mode)
|
|
69
|
+
* @returns {{ alreadyConfigured: boolean }}
|
|
70
|
+
*/
|
|
71
|
+
export async function enableMcp({ silent = false } = {}) {
|
|
72
|
+
const sp = settingsPath();
|
|
73
|
+
const settings = await readSettings(sp);
|
|
74
|
+
|
|
75
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
76
|
+
|
|
77
|
+
const already =
|
|
78
|
+
settings.mcpServers.noctrace != null &&
|
|
79
|
+
settings.mcpServers.noctrace.command === 'npx' &&
|
|
80
|
+
Array.isArray(settings.mcpServers.noctrace.args) &&
|
|
81
|
+
settings.mcpServers.noctrace.args.includes('--mcp');
|
|
82
|
+
|
|
83
|
+
settings.mcpServers.noctrace = {
|
|
84
|
+
command: 'npx',
|
|
85
|
+
args: ['-y', 'noctrace', '--mcp'],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await writeSettings(sp, settings);
|
|
89
|
+
|
|
90
|
+
if (!silent) {
|
|
91
|
+
if (already) {
|
|
92
|
+
console.log('[noctrace] MCP server already configured — updated entry.');
|
|
93
|
+
} else {
|
|
94
|
+
console.log('[noctrace] Registered noctrace MCP server in Claude Code settings.');
|
|
95
|
+
}
|
|
96
|
+
console.log('[noctrace] Settings written to:', sp);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { alreadyConfigured: already };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Remove the noctrace MCP server entry from ~/.claude/settings.json.
|
|
104
|
+
*
|
|
105
|
+
* Cleans up the mcpServers key entirely if it becomes empty.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {boolean} [opts.silent] Suppress console output
|
|
109
|
+
* @returns {{ wasConfigured: boolean }}
|
|
110
|
+
*/
|
|
111
|
+
export async function disableMcp({ silent = false } = {}) {
|
|
112
|
+
const sp = settingsPath();
|
|
113
|
+
const settings = await readSettings(sp);
|
|
114
|
+
|
|
115
|
+
const wasConfigured = settings.mcpServers?.noctrace != null;
|
|
116
|
+
|
|
117
|
+
if (!wasConfigured) {
|
|
118
|
+
if (!silent) {
|
|
119
|
+
console.log('[noctrace] Noctrace is not configured in Claude Code.');
|
|
120
|
+
}
|
|
121
|
+
return { wasConfigured: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
delete settings.mcpServers.noctrace;
|
|
125
|
+
|
|
126
|
+
// Remove the mcpServers key entirely if it's now empty
|
|
127
|
+
if (Object.keys(settings.mcpServers).length === 0) {
|
|
128
|
+
delete settings.mcpServers;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await writeSettings(sp, settings);
|
|
132
|
+
|
|
133
|
+
if (!silent) {
|
|
134
|
+
console.log('[noctrace] Removed noctrace MCP server from Claude Code settings.');
|
|
135
|
+
console.log('[noctrace] Settings written to:', sp);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { wasConfigured: true };
|
|
139
|
+
}
|
package/bin/noctrace-mcp.js
CHANGED
|
@@ -1,44 +1,199 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* MCP server wrapper for the noctrace plugin.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Behaviour:
|
|
6
|
+
* 1. Discovers the current Claude Code session's JSONL path from env vars.
|
|
7
|
+
* 2. Checks whether noctrace is already running on port 4117.
|
|
8
|
+
* 3. If not running, starts the Express server and opens the browser.
|
|
9
|
+
* 4. Registers this session via POST /api/sessions/register.
|
|
10
|
+
* 5. Speaks JSON-RPC 2.0 over stdio to satisfy Claude Code's MCP protocol.
|
|
11
|
+
* 6. On exit (SIGTERM / SIGINT / stdin close), unregisters the session.
|
|
12
|
+
*
|
|
13
|
+
* Session path discovery order:
|
|
14
|
+
* a. CLAUDE_SESSION_PATH env var (direct path to the .jsonl file)
|
|
15
|
+
* b. CLAUDE_PROJECT_DIR or PWD → compute project slug → find newest .jsonl
|
|
16
|
+
* c. Fall back to null (register with null; file watcher will pick it up)
|
|
8
17
|
*/
|
|
9
18
|
import { createInterface } from 'node:readline';
|
|
19
|
+
import fs from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
|
|
23
|
+
const VERSION = '0.5.1';
|
|
24
|
+
const NOCTRACE_PORT = 4117;
|
|
25
|
+
const BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Session path discovery
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert an absolute filesystem path to the Claude project slug format.
|
|
33
|
+
* Slugs replace every "/" with "-" and strip any leading "-".
|
|
34
|
+
* Example: /Users/lam/dev/noctrace → -Users-lam-dev-noctrace
|
|
35
|
+
*/
|
|
36
|
+
function pathToSlug(absPath) {
|
|
37
|
+
return absPath.replace(/\//g, '-');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return the path to the most recently modified .jsonl file in `dir`,
|
|
42
|
+
* or null if the directory is empty / inaccessible.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} dir — absolute path to a project directory
|
|
45
|
+
* @returns {Promise<string|null>}
|
|
46
|
+
*/
|
|
47
|
+
async function newestJsonl(dir) {
|
|
48
|
+
let files;
|
|
49
|
+
try {
|
|
50
|
+
files = await fs.readdir(dir);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
|
|
55
|
+
if (jsonlFiles.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
let newest = null;
|
|
58
|
+
let newestMtime = -Infinity;
|
|
59
|
+
|
|
60
|
+
for (const file of jsonlFiles) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = await fs.stat(path.join(dir, file));
|
|
63
|
+
if (stat.mtimeMs > newestMtime) {
|
|
64
|
+
newestMtime = stat.mtimeMs;
|
|
65
|
+
newest = path.join(dir, file);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// skip
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return newest;
|
|
72
|
+
}
|
|
10
73
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Discover the JSONL session path for the current Claude Code session.
|
|
76
|
+
*
|
|
77
|
+
* @returns {Promise<string|null>}
|
|
78
|
+
*/
|
|
79
|
+
async function discoverSessionPath() {
|
|
80
|
+
// Option a: direct env var
|
|
81
|
+
if (process.env.CLAUDE_SESSION_PATH) {
|
|
82
|
+
return process.env.CLAUDE_SESSION_PATH;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Option b: derive from project directory
|
|
86
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.PWD ?? null;
|
|
87
|
+
if (!projectDir) return null;
|
|
88
|
+
|
|
89
|
+
const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
90
|
+
const slug = pathToSlug(projectDir);
|
|
91
|
+
const projectSessionDir = path.join(claudeHome, 'projects', slug);
|
|
92
|
+
|
|
93
|
+
return newestJsonl(projectSessionDir);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Port / server helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check whether noctrace is already running on the configured port.
|
|
102
|
+
* Uses the /api/health endpoint; resolves true if reachable, false otherwise.
|
|
103
|
+
*
|
|
104
|
+
* @returns {Promise<boolean>}
|
|
105
|
+
*/
|
|
106
|
+
async function isServerRunning() {
|
|
107
|
+
try {
|
|
108
|
+
const { default: http } = await import('node:http');
|
|
109
|
+
return await new Promise((resolve) => {
|
|
110
|
+
const req = http.get(`${BASE_URL}/api/health`, { timeout: 1500 }, (res) => {
|
|
111
|
+
resolve(res.statusCode >= 200 && res.statusCode < 500);
|
|
112
|
+
});
|
|
113
|
+
req.on('error', () => resolve(false));
|
|
114
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
14
120
|
|
|
15
|
-
|
|
16
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Start the noctrace Express server by importing the compiled entry point.
|
|
123
|
+
* Sets NOCTRACE_NO_AUTOSTART so the module does not start a second server
|
|
124
|
+
* instance when imported as a side-effect.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
async function startNoctraceServer() {
|
|
17
129
|
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
18
130
|
const { startServer } = await import('../dist/server/server/index.js');
|
|
19
|
-
|
|
131
|
+
await startServer();
|
|
132
|
+
}
|
|
20
133
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Session registration
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* POST a session path to the noctrace register/unregister endpoint.
|
|
140
|
+
*
|
|
141
|
+
* @param {'register'|'unregister'} action
|
|
142
|
+
* @param {string} sessionPath
|
|
143
|
+
* @returns {Promise<void>}
|
|
144
|
+
*/
|
|
145
|
+
async function postSessionAction(action, sessionPath) {
|
|
146
|
+
const { default: http } = await import('node:http');
|
|
147
|
+
const body = JSON.stringify({ sessionPath });
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const req = http.request(
|
|
150
|
+
{
|
|
151
|
+
hostname: 'localhost',
|
|
152
|
+
port: NOCTRACE_PORT,
|
|
153
|
+
path: `/api/sessions/${action}`,
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: {
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
'Content-Length': Buffer.byteLength(body),
|
|
158
|
+
},
|
|
159
|
+
timeout: 3000,
|
|
160
|
+
},
|
|
161
|
+
() => resolve(),
|
|
162
|
+
);
|
|
163
|
+
req.on('error', () => resolve());
|
|
164
|
+
req.on('timeout', () => { req.destroy(); resolve(); });
|
|
165
|
+
req.write(body);
|
|
166
|
+
req.end();
|
|
167
|
+
});
|
|
27
168
|
}
|
|
28
169
|
|
|
29
|
-
//
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// JSON-RPC helpers
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Write a JSON-RPC 2.0 response to stdout.
|
|
176
|
+
* All other output must go to stderr (stdout is the MCP channel).
|
|
177
|
+
*
|
|
178
|
+
* @param {unknown} id
|
|
179
|
+
* @param {unknown} result
|
|
180
|
+
*/
|
|
30
181
|
function respond(id, result) {
|
|
31
182
|
const msg = JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
32
183
|
process.stdout.write(`${msg}\n`);
|
|
33
184
|
}
|
|
34
185
|
|
|
35
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Handle a single JSON-RPC message from Claude Code.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} line
|
|
190
|
+
*/
|
|
36
191
|
function handleMessage(line) {
|
|
37
192
|
let msg;
|
|
38
193
|
try {
|
|
39
194
|
msg = JSON.parse(line);
|
|
40
195
|
} catch {
|
|
41
|
-
return;
|
|
196
|
+
return;
|
|
42
197
|
}
|
|
43
198
|
|
|
44
199
|
const { id, method, params } = msg;
|
|
@@ -53,8 +208,7 @@ function handleMessage(line) {
|
|
|
53
208
|
}
|
|
54
209
|
|
|
55
210
|
if (method === 'notifications/initialized') {
|
|
56
|
-
//
|
|
57
|
-
return;
|
|
211
|
+
return; // no response for notifications
|
|
58
212
|
}
|
|
59
213
|
|
|
60
214
|
if (method === 'tools/list') {
|
|
@@ -73,15 +227,12 @@ function handleMessage(line) {
|
|
|
73
227
|
if (method === 'tools/call') {
|
|
74
228
|
const toolName = params?.name;
|
|
75
229
|
if (toolName === 'open_dashboard') {
|
|
76
|
-
|
|
77
|
-
// Open browser
|
|
78
|
-
import('open').then((m) => m.default(url)).catch(() => {});
|
|
230
|
+
import('open').then((m) => m.default(BASE_URL)).catch(() => {});
|
|
79
231
|
respond(id, {
|
|
80
|
-
content: [{ type: 'text', text: `Noctrace dashboard: ${
|
|
232
|
+
content: [{ type: 'text', text: `Noctrace dashboard: ${BASE_URL}` }],
|
|
81
233
|
});
|
|
82
234
|
return;
|
|
83
235
|
}
|
|
84
|
-
// Unknown tool
|
|
85
236
|
respond(id, {
|
|
86
237
|
content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
|
|
87
238
|
isError: true,
|
|
@@ -95,20 +246,87 @@ function handleMessage(line) {
|
|
|
95
246
|
}
|
|
96
247
|
}
|
|
97
248
|
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
98
250
|
// Main
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
99
253
|
async function main() {
|
|
100
|
-
await
|
|
254
|
+
let sessionPath = await discoverSessionPath();
|
|
255
|
+
|
|
256
|
+
// Check if noctrace is already running; if not, start it (first MCP process wins).
|
|
257
|
+
// Use a retry loop to handle the race where two MCP processes start simultaneously.
|
|
258
|
+
const running = await isServerRunning();
|
|
259
|
+
if (!running) {
|
|
260
|
+
process.stderr.write('[noctrace-mcp] Starting noctrace server...\n');
|
|
261
|
+
try {
|
|
262
|
+
await startNoctraceServer();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err.code === 'EADDRINUSE') {
|
|
265
|
+
// Another process won the race — verify it's reachable before continuing.
|
|
266
|
+
const nowRunning = await isServerRunning();
|
|
267
|
+
if (!nowRunning) {
|
|
268
|
+
process.stderr.write('[noctrace-mcp] Fatal: port in use but server is not responding\n');
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
process.stderr.write('[noctrace-mcp] Server started by peer process — continuing\n');
|
|
272
|
+
} else {
|
|
273
|
+
// A real startup error (e.g. missing dist/, permission denied) — surface it.
|
|
274
|
+
process.stderr.write(`[noctrace-mcp] Fatal: could not start server: ${err.message}\n`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Open the browser — only the first MCP process to start the server does this
|
|
280
|
+
try {
|
|
281
|
+
const open = (await import('open')).default;
|
|
282
|
+
await open(BASE_URL);
|
|
283
|
+
} catch {
|
|
284
|
+
// Non-fatal — user can navigate manually
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Retry session discovery once — Claude Code may still be creating the session file
|
|
289
|
+
// when the MCP server starts. Wait 1 second and prefer the newer result.
|
|
290
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
291
|
+
const retryPath = await discoverSessionPath();
|
|
292
|
+
if (retryPath && retryPath !== sessionPath) {
|
|
293
|
+
sessionPath = retryPath;
|
|
294
|
+
}
|
|
101
295
|
|
|
296
|
+
if (sessionPath) {
|
|
297
|
+
process.stderr.write(`[noctrace-mcp] Session: ${sessionPath}\n`);
|
|
298
|
+
} else {
|
|
299
|
+
process.stderr.write('[noctrace-mcp] Could not discover session path — proceeding without registration\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Register this session with the running noctrace server
|
|
303
|
+
if (sessionPath) {
|
|
304
|
+
await postSessionAction('register', sessionPath);
|
|
305
|
+
process.stderr.write('[noctrace-mcp] Session registered\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Cleanup: unregister on process exit
|
|
309
|
+
let cleaned = false;
|
|
310
|
+
async function cleanup() {
|
|
311
|
+
if (cleaned) return;
|
|
312
|
+
cleaned = true;
|
|
313
|
+
if (sessionPath) {
|
|
314
|
+
await postSessionAction('unregister', sessionPath).catch(() => {});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
process.on('SIGTERM', () => { void cleanup().then(() => process.exit(0)); });
|
|
319
|
+
process.on('SIGINT', () => { void cleanup().then(() => process.exit(0)); });
|
|
320
|
+
|
|
321
|
+
// Speak JSON-RPC over stdio for Claude Code's MCP protocol
|
|
102
322
|
const rl = createInterface({ input: process.stdin });
|
|
103
323
|
rl.on('line', handleMessage);
|
|
104
|
-
|
|
105
|
-
// Keep alive until stdin closes (Claude Code manages our lifecycle)
|
|
106
324
|
rl.on('close', () => {
|
|
107
|
-
process.exit(0);
|
|
325
|
+
void cleanup().then(() => process.exit(0));
|
|
108
326
|
});
|
|
109
327
|
}
|
|
110
328
|
|
|
111
329
|
main().catch((err) => {
|
|
112
|
-
|
|
330
|
+
process.stderr.write(`[noctrace-mcp] Fatal: ${err.message}\n`);
|
|
113
331
|
process.exit(1);
|
|
114
332
|
});
|
package/bin/noctrace.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { enableMcp, disableMcp } from './claude-config.js';
|
|
5
6
|
|
|
6
7
|
const NOCTRACE_PORT = 4117;
|
|
7
8
|
const NOCTRACE_BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
|
|
@@ -177,6 +178,36 @@ if (args.includes('--uninstall-hooks')) {
|
|
|
177
178
|
process.exit(0);
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
if (args.includes('--enable')) {
|
|
182
|
+
const { alreadyConfigured } = await enableMcp();
|
|
183
|
+
if (alreadyConfigured) {
|
|
184
|
+
console.log('[noctrace] MCP server entry updated.');
|
|
185
|
+
} else {
|
|
186
|
+
console.log('[noctrace] ✓ Noctrace will auto-start with your next Claude Code session.');
|
|
187
|
+
console.log('[noctrace] Run "noctrace --disable" to remove.');
|
|
188
|
+
}
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (args.includes('--disable')) {
|
|
193
|
+
const { wasConfigured } = await disableMcp();
|
|
194
|
+
if (wasConfigured) {
|
|
195
|
+
console.log('[noctrace] ✓ Noctrace removed from Claude Code.');
|
|
196
|
+
} else {
|
|
197
|
+
console.log('[noctrace] Noctrace is not configured in Claude Code.');
|
|
198
|
+
}
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (args.includes('--mcp')) {
|
|
203
|
+
// MCP mode: boot the Express server and speak JSON-RPC over stdio.
|
|
204
|
+
// stdout is the JSON-RPC channel — all logging must go to stderr.
|
|
205
|
+
process.stderr.write('[noctrace-mcp] Starting MCP server...\n');
|
|
206
|
+
await import('./noctrace-mcp.js');
|
|
207
|
+
// noctrace-mcp.js takes over from here (runs main() internally)
|
|
208
|
+
process.exit(0); // unreachable — mcp keeps process alive via readline
|
|
209
|
+
}
|
|
210
|
+
|
|
180
211
|
// Default: start the server and open the browser
|
|
181
212
|
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
182
213
|
const { startServer } = await import('../dist/server/server/index.js');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Auto-register noctrace as an MCP server in Claude Code settings.
|
|
4
|
+
* Runs after `npm install -g noctrace`.
|
|
5
|
+
*
|
|
6
|
+
* Only acts if ~/.claude/ already exists (i.e. Claude Code is installed).
|
|
7
|
+
* Never fails — always exits with code 0 so the install is never interrupted.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { claudeDir, enableMcp } from './claude-config.js';
|
|
11
|
+
|
|
12
|
+
async function run() {
|
|
13
|
+
// Check whether Claude Code is installed
|
|
14
|
+
try {
|
|
15
|
+
await fs.access(claudeDir());
|
|
16
|
+
} catch {
|
|
17
|
+
// ~/.claude doesn't exist — Claude Code is not installed, skip silently
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check if already configured
|
|
22
|
+
const settingsFile = (await import('./claude-config.js')).settingsPath();
|
|
23
|
+
let alreadyConfigured = false;
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(settingsFile, 'utf8');
|
|
26
|
+
const settings = JSON.parse(raw);
|
|
27
|
+
alreadyConfigured = !!settings?.mcpServers?.noctrace;
|
|
28
|
+
} catch {
|
|
29
|
+
// No settings file or invalid JSON — not configured
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (alreadyConfigured) {
|
|
33
|
+
return; // Already set up, nothing to say
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Don't auto-modify config — just tell the user how to opt in
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(' \x1b[36mnoctrace\x1b[0m installed successfully.');
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(' Auto-start with Claude Code (recommended):');
|
|
41
|
+
console.log(' \x1b[1mnoctrace --enable\x1b[0m');
|
|
42
|
+
console.log('');
|
|
43
|
+
console.log(' Or run standalone:');
|
|
44
|
+
console.log(' \x1b[1mnoctrace\x1b[0m');
|
|
45
|
+
console.log('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
run().catch(() => {
|
|
49
|
+
// Silently swallow all errors — never break the install
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remove noctrace MCP server registration from Claude Code settings.
|
|
4
|
+
* Runs before `npm uninstall -g noctrace`.
|
|
5
|
+
*
|
|
6
|
+
* Never fails — always exits with code 0 so the uninstall is never interrupted.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import { claudeDir, disableMcp } from './claude-config.js';
|
|
10
|
+
|
|
11
|
+
async function run() {
|
|
12
|
+
// Check whether Claude Code is installed before touching anything
|
|
13
|
+
try {
|
|
14
|
+
await fs.access(claudeDir());
|
|
15
|
+
} catch {
|
|
16
|
+
// ~/.claude doesn't exist — nothing to clean up
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { wasConfigured } = await disableMcp({ silent: true });
|
|
21
|
+
|
|
22
|
+
if (wasConfigured) {
|
|
23
|
+
console.log('[noctrace] Removed noctrace MCP server from Claude Code settings.');
|
|
24
|
+
}
|
|
25
|
+
// If not configured, exit quietly
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
run().catch(() => {
|
|
29
|
+
// Silently swallow all errors — never block the uninstall
|
|
30
|
+
}).finally(() => {
|
|
31
|
+
process.exit(0);
|
|
32
|
+
});
|
|
@@ -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}.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-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}.items-center{align-items:center}.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}.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;--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)}body{background-color:var(--ctp-base);color:var(--ctp-text);font-family:SF Mono,Cascadia Code,JetBrains Mono,Fira Code,ui-monospace,monospace}@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}
|