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.
@@ -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
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "noctrace": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/bin/noctrace-mcp.js"],
6
+ "env": {
7
+ "NODE_ENV": "production"
8
+ }
9
+ }
10
+ }
11
+ }
package/README.md CHANGED
@@ -37,7 +37,13 @@ npm install -g noctrace
37
37
  noctrace
38
38
  ```
39
39
 
40
- Requires Node.js 20+. That's it. No config, no hooks, no API keys.
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
  ![Noctrace waterfall timeline](docs/screenshots/noctrace-waterfall.png)
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 hooks to install. No config files. No cloud. Everything stays local.
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 { startServer } from '../dist/server/server/index.js';
3
- import open from 'open';
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);