noctrace 0.3.6 → 0.4.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/.claude-plugin/plugin.json +13 -0
- package/.mcp.json +11 -0
- package/README.md +16 -2
- package/bin/noctrace-mcp.js +114 -0
- package/bin/noctrace.js +181 -2
- package/dist/client/assets/{index-DD9m5CfY.js → index-BisxBJUn.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/server/server/index.js +33 -48
- package/dist/server/server/routes/api.js +50 -5
- package/dist/server/server/watcher.js +3 -3
- package/dist/server/server/ws.js +7 -1
- package/dist/server/shared/drift.js +86 -0
- package/hooks/hooks.json +68 -0
- package/package.json +5 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "noctrace",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Nyktora Group LLC",
|
|
7
|
+
"url": "https://nyktora.com"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://nyktora.github.io/noctrace/",
|
|
10
|
+
"repository": "https://github.com/nyktora/noctrace",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": ["devtools", "waterfall", "observability", "timeline", "context-health"]
|
|
13
|
+
}
|
package/.mcp.json
ADDED
package/README.md
CHANGED
|
@@ -37,7 +37,13 @@ npm install -g noctrace
|
|
|
37
37
|
noctrace
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
### As a Claude Code Plugin
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude plugin install nyktora/noctrace
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Node.js 20+. That's it. No config required. Optional hooks for real-time events.
|
|
41
47
|
|
|
42
48
|
## Features
|
|
43
49
|
|
|
@@ -54,6 +60,9 @@ Requires Node.js 20+. That's it. No config, no hooks, no API keys.
|
|
|
54
60
|
- **Detail panel** — click any row for full tool input/output, resizable
|
|
55
61
|
- **Re-read detection** — flags duplicate file reads that waste context
|
|
56
62
|
- **Dark theme** — Catppuccin Mocha palette
|
|
63
|
+
- **Session export** — share sessions as standalone offline HTML files
|
|
64
|
+
- **Hooks integration** — optional real-time event streaming from Claude Code
|
|
65
|
+
- **Context Drift Rate** — detect accelerating token growth before context rot hits
|
|
57
66
|
|
|
58
67
|

|
|
59
68
|
|
|
@@ -89,7 +98,7 @@ Click any row to inspect the full tool input and output. Two-column layout shows
|
|
|
89
98
|
4. Parses tool_use/tool_result pairs into a waterfall timeline
|
|
90
99
|
5. Watches active session files for real-time updates via WebSocket
|
|
91
100
|
|
|
92
|
-
No
|
|
101
|
+
No config files. No cloud. Everything stays local. Optional hooks for richer real-time data.
|
|
93
102
|
|
|
94
103
|
## Configuration
|
|
95
104
|
|
|
@@ -98,6 +107,11 @@ No hooks to install. No config files. No cloud. Everything stays local.
|
|
|
98
107
|
| `PORT` | `4117` | Server port (auto-increments if busy) |
|
|
99
108
|
| `CLAUDE_HOME` | `~/.claude` | Override Claude home directory |
|
|
100
109
|
|
|
110
|
+
| CLI Flag | Description |
|
|
111
|
+
|----------|-------------|
|
|
112
|
+
| `--install-hooks` | Configure Claude Code to push real-time events to noctrace |
|
|
113
|
+
| `--uninstall-hooks` | Remove noctrace hooks from Claude Code |
|
|
114
|
+
|
|
101
115
|
## Development
|
|
102
116
|
|
|
103
117
|
```bash
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minimal MCP server wrapper for the noctrace plugin.
|
|
4
|
+
*
|
|
5
|
+
* Starts the noctrace Express server as a side effect and speaks just enough
|
|
6
|
+
* MCP (JSON-RPC 2.0 over stdio) to stay alive as a Claude Code managed process.
|
|
7
|
+
* Exposes a single `open_dashboard` tool so Claude can tell the user the URL.
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
|
|
11
|
+
const VERSION = '0.4.0';
|
|
12
|
+
let serverPort = null;
|
|
13
|
+
let browserOpened = false;
|
|
14
|
+
|
|
15
|
+
// Start the Express server (lazy import to avoid loading before needed)
|
|
16
|
+
async function boot() {
|
|
17
|
+
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
18
|
+
const { startServer } = await import('../dist/server/server/index.js');
|
|
19
|
+
serverPort = await startServer();
|
|
20
|
+
|
|
21
|
+
// Open browser once on first start
|
|
22
|
+
if (!browserOpened) {
|
|
23
|
+
const open = (await import('open')).default;
|
|
24
|
+
await open(`http://localhost:${serverPort}`);
|
|
25
|
+
browserOpened = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// JSON-RPC response helper
|
|
30
|
+
function respond(id, result) {
|
|
31
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
32
|
+
process.stdout.write(`${msg}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle incoming JSON-RPC messages from Claude Code
|
|
36
|
+
function handleMessage(line) {
|
|
37
|
+
let msg;
|
|
38
|
+
try {
|
|
39
|
+
msg = JSON.parse(line);
|
|
40
|
+
} catch {
|
|
41
|
+
return; // ignore malformed input
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { id, method, params } = msg;
|
|
45
|
+
|
|
46
|
+
if (method === 'initialize') {
|
|
47
|
+
respond(id, {
|
|
48
|
+
protocolVersion: '2024-11-05',
|
|
49
|
+
capabilities: { tools: {} },
|
|
50
|
+
serverInfo: { name: 'noctrace', version: VERSION },
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (method === 'notifications/initialized') {
|
|
56
|
+
// No response needed for notifications
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (method === 'tools/list') {
|
|
61
|
+
respond(id, {
|
|
62
|
+
tools: [
|
|
63
|
+
{
|
|
64
|
+
name: 'open_dashboard',
|
|
65
|
+
description: 'Open the noctrace waterfall dashboard in the browser',
|
|
66
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (method === 'tools/call') {
|
|
74
|
+
const toolName = params?.name;
|
|
75
|
+
if (toolName === 'open_dashboard') {
|
|
76
|
+
const url = `http://localhost:${serverPort ?? 4117}`;
|
|
77
|
+
// Open browser
|
|
78
|
+
import('open').then((m) => m.default(url)).catch(() => {});
|
|
79
|
+
respond(id, {
|
|
80
|
+
content: [{ type: 'text', text: `Noctrace dashboard: ${url}` }],
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Unknown tool
|
|
85
|
+
respond(id, {
|
|
86
|
+
content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
|
|
87
|
+
isError: true,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Unknown method — respond with empty result to avoid hanging
|
|
93
|
+
if (id !== undefined) {
|
|
94
|
+
respond(id, {});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Main
|
|
99
|
+
async function main() {
|
|
100
|
+
await boot();
|
|
101
|
+
|
|
102
|
+
const rl = createInterface({ input: process.stdin });
|
|
103
|
+
rl.on('line', handleMessage);
|
|
104
|
+
|
|
105
|
+
// Keep alive until stdin closes (Claude Code manages our lifecycle)
|
|
106
|
+
rl.on('close', () => {
|
|
107
|
+
process.exit(0);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch((err) => {
|
|
112
|
+
console.error('[noctrace-mcp] Fatal:', err.message);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
package/bin/noctrace.js
CHANGED
|
@@ -1,7 +1,186 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
|
|
6
|
+
const NOCTRACE_PORT = 4117;
|
|
7
|
+
const NOCTRACE_BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
|
|
8
|
+
const HOOKS_ENDPOINT = `${NOCTRACE_BASE_URL}/api/hooks`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The curl command used as the hook body.
|
|
12
|
+
* Reads the hook event JSON from stdin and POSTs it to noctrace.
|
|
13
|
+
* `--data-raw "$(cat)"` captures all of stdin and sends it as the request body.
|
|
14
|
+
*/
|
|
15
|
+
const HOOK_COMMAND = `curl -s -X POST ${HOOKS_ENDPOINT} -H 'Content-Type: application/json' --data-raw "$(cat)"`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The set of Claude Code hook event names noctrace subscribes to.
|
|
19
|
+
*/
|
|
20
|
+
const HOOK_EVENT_NAMES = [
|
|
21
|
+
'PostToolUse',
|
|
22
|
+
'SubagentStart',
|
|
23
|
+
'SubagentStop',
|
|
24
|
+
'Stop',
|
|
25
|
+
'PreCompact',
|
|
26
|
+
'PostCompact',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read and parse ~/.claude/settings.json.
|
|
31
|
+
* Returns an empty object if the file doesn't exist yet.
|
|
32
|
+
*/
|
|
33
|
+
async function readSettings(settingsPath) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await fs.readFile(settingsPath, 'utf8');
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code === 'ENOENT') return {};
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write settings back to disk, creating parent directories as needed.
|
|
45
|
+
*/
|
|
46
|
+
async function writeSettings(settingsPath, settings) {
|
|
47
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
48
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install noctrace hooks into ~/.claude/settings.json.
|
|
53
|
+
* For each hook event name, adds a "command" hook that POSTs the event
|
|
54
|
+
* payload to the noctrace HTTP endpoint.
|
|
55
|
+
* Skips events that already have a noctrace hook registered.
|
|
56
|
+
*/
|
|
57
|
+
async function installHooks() {
|
|
58
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
59
|
+
const settings = await readSettings(settingsPath);
|
|
60
|
+
|
|
61
|
+
if (!settings.hooks) settings.hooks = {};
|
|
62
|
+
|
|
63
|
+
const added = [];
|
|
64
|
+
const skipped = [];
|
|
65
|
+
|
|
66
|
+
for (const eventName of HOOK_EVENT_NAMES) {
|
|
67
|
+
if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
|
|
68
|
+
|
|
69
|
+
const existing = settings.hooks[eventName];
|
|
70
|
+
|
|
71
|
+
// Check whether a noctrace hook is already registered for this event
|
|
72
|
+
const alreadyInstalled = existing.some(
|
|
73
|
+
(entry) =>
|
|
74
|
+
Array.isArray(entry.hooks) &&
|
|
75
|
+
entry.hooks.some(
|
|
76
|
+
(h) => h.type === 'command' && typeof h.command === 'string' && h.command.includes(HOOKS_ENDPOINT),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (alreadyInstalled) {
|
|
81
|
+
skipped.push(eventName);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
existing.push({
|
|
86
|
+
hooks: [{
|
|
87
|
+
type: 'command',
|
|
88
|
+
command: HOOK_COMMAND,
|
|
89
|
+
async: true,
|
|
90
|
+
}],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
added.push(eventName);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await writeSettings(settingsPath, settings);
|
|
97
|
+
|
|
98
|
+
if (added.length > 0) {
|
|
99
|
+
console.log(`[noctrace] Installed hooks for: ${added.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (skipped.length > 0) {
|
|
102
|
+
console.log(`[noctrace] Already installed (skipped): ${skipped.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
console.log(`[noctrace] Settings written to: ${settingsPath}`);
|
|
105
|
+
console.log(`[noctrace] Hook events will be forwarded to ${HOOKS_ENDPOINT}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove noctrace hooks from ~/.claude/settings.json.
|
|
110
|
+
* Only removes hooks whose command string contains the noctrace endpoint —
|
|
111
|
+
* other hooks for the same events are left untouched.
|
|
112
|
+
*/
|
|
113
|
+
async function uninstallHooks() {
|
|
114
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
115
|
+
const settings = await readSettings(settingsPath);
|
|
116
|
+
|
|
117
|
+
if (!settings.hooks) {
|
|
118
|
+
console.log('[noctrace] No hooks section found in settings.json — nothing to remove.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const removed = [];
|
|
123
|
+
|
|
124
|
+
for (const eventName of HOOK_EVENT_NAMES) {
|
|
125
|
+
const existing = settings.hooks[eventName];
|
|
126
|
+
if (!Array.isArray(existing)) continue;
|
|
127
|
+
|
|
128
|
+
const before = existing.length;
|
|
129
|
+
settings.hooks[eventName] = existing.filter(
|
|
130
|
+
(entry) =>
|
|
131
|
+
!(
|
|
132
|
+
Array.isArray(entry.hooks) &&
|
|
133
|
+
entry.hooks.some(
|
|
134
|
+
(h) => h.type === 'command' && typeof h.command === 'string' && h.command.includes(HOOKS_ENDPOINT),
|
|
135
|
+
)
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (settings.hooks[eventName].length < before) {
|
|
140
|
+
removed.push(eventName);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clean up empty arrays
|
|
144
|
+
if (settings.hooks[eventName].length === 0) {
|
|
145
|
+
delete settings.hooks[eventName];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clean up empty hooks object
|
|
150
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
151
|
+
delete settings.hooks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await writeSettings(settingsPath, settings);
|
|
155
|
+
|
|
156
|
+
if (removed.length > 0) {
|
|
157
|
+
console.log(`[noctrace] Removed hooks for: ${removed.join(', ')}`);
|
|
158
|
+
console.log(`[noctrace] Settings written to: ${settingsPath}`);
|
|
159
|
+
} else {
|
|
160
|
+
console.log('[noctrace] No noctrace hooks found — nothing to remove.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// CLI dispatch
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
|
|
170
|
+
if (args.includes('--install-hooks')) {
|
|
171
|
+
await installHooks();
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (args.includes('--uninstall-hooks')) {
|
|
176
|
+
await uninstallHooks();
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Default: start the server and open the browser
|
|
181
|
+
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
182
|
+
const { startServer } = await import('../dist/server/server/index.js');
|
|
183
|
+
const open = (await import('open')).default;
|
|
5
184
|
const port = await startServer();
|
|
6
185
|
const url = `http://localhost:${port}`;
|
|
7
186
|
await open(url);
|