noctrace 0.8.2 → 1.0.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/README.md +2 -0
- package/bin/docker-watcher.sh +108 -0
- package/bin/noctrace-mcp.js +47 -8
- package/bin/noctrace.js +123 -0
- package/dist/client/assets/index-D3XepZ5e.js +30 -0
- package/dist/client/index.html +1 -1
- package/dist/server/server/docker.js +152 -0
- package/dist/server/server/routes/api.js +100 -1
- package/dist/server/shared/filter.js +5 -0
- package/dist/server/shared/parser.js +100 -0
- package/hooks/hooks.json +9 -9
- package/package.json +1 -1
- package/dist/client/assets/index-B37clQwh.js +0 -30
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ Requires Node.js 20+. Optional `--install-hooks` flag enables real-time hook eve
|
|
|
87
87
|
- **API Error Markers** — rate limit, billing, and auth failures appear as full-width red alert banners on the timeline
|
|
88
88
|
- **Agent Teams Panel** — detects running Agent Teams at `~/.claude/teams/`, shows members and task counts in a flyout
|
|
89
89
|
- **Context Startup Flyout** — shows which instruction files (CLAUDE.md and others) loaded at session start with estimated token counts, parsed from JSONL system records
|
|
90
|
+
- **Docker Support** — `npx noctrace --docker <container>` attaches to a running Docker container, injects a lightweight watcher, and streams JSONL events back to your host in real time. Zero container setup required
|
|
90
91
|
|
|
91
92
|

|
|
92
93
|
|
|
@@ -141,6 +142,7 @@ No config files. No cloud. Everything stays local. Optional hooks for richer rea
|
|
|
141
142
|
|
|
142
143
|
| CLI Flag | Description |
|
|
143
144
|
|----------|-------------|
|
|
145
|
+
| `--docker <container>` | Attach to a running Docker container and stream its Claude Code sessions back to your host. Zero container setup |
|
|
144
146
|
| `--install-hooks` | Configure Claude Code to push real-time events to noctrace |
|
|
145
147
|
| `--uninstall-hooks` | Remove noctrace hooks from Claude Code |
|
|
146
148
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Noctrace Docker Watcher — injected into containers to stream JSONL to host.
|
|
3
|
+
# Usage: docker-watcher.sh <claude_dir> <noctrace_url> <container_name>
|
|
4
|
+
# Streams JSONL lines via curl POST to the noctrace /api/docker/stream endpoint.
|
|
5
|
+
# Sends heartbeats every 10 seconds to /api/docker/heartbeat.
|
|
6
|
+
|
|
7
|
+
CLAUDE_DIR="$1"
|
|
8
|
+
NOCTRACE_URL="$2"
|
|
9
|
+
CONTAINER_NAME="$3"
|
|
10
|
+
PROJECTS_DIR="$CLAUDE_DIR/projects"
|
|
11
|
+
|
|
12
|
+
if [ -z "$CLAUDE_DIR" ] || [ -z "$NOCTRACE_URL" ] || [ -z "$CONTAINER_NAME" ]; then
|
|
13
|
+
echo "Usage: docker-watcher.sh <claude_dir> <noctrace_url> <container_name>" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Track watched files to avoid duplicate tail processes
|
|
18
|
+
WATCHED="/tmp/.noctrace-watched-$$"
|
|
19
|
+
PIDS="/tmp/.noctrace-pids-$$"
|
|
20
|
+
touch "$WATCHED" "$PIDS"
|
|
21
|
+
|
|
22
|
+
cleanup() {
|
|
23
|
+
# Kill all tail processes we started
|
|
24
|
+
while IFS= read -r pid; do
|
|
25
|
+
kill "$pid" 2>/dev/null
|
|
26
|
+
done < "$PIDS"
|
|
27
|
+
rm -f "$WATCHED" "$PIDS"
|
|
28
|
+
exit 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
trap cleanup TERM INT
|
|
32
|
+
|
|
33
|
+
# Detect HTTP client
|
|
34
|
+
if command -v curl >/dev/null 2>&1; then
|
|
35
|
+
HTTP_CMD="curl"
|
|
36
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
37
|
+
HTTP_CMD="wget"
|
|
38
|
+
else
|
|
39
|
+
echo "[noctrace-watcher] Neither curl nor wget found in container" >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
post_stream() {
|
|
44
|
+
file="$1"
|
|
45
|
+
if [ "$HTTP_CMD" = "curl" ]; then
|
|
46
|
+
curl -s -X POST "$NOCTRACE_URL/api/docker/stream" \
|
|
47
|
+
-H "Content-Type: text/plain" \
|
|
48
|
+
-H "X-Container-Name: $CONTAINER_NAME" \
|
|
49
|
+
-H "X-Container-Path: $file" \
|
|
50
|
+
--data-binary @- || true
|
|
51
|
+
else
|
|
52
|
+
wget -q -O /dev/null --post-file=- \
|
|
53
|
+
--header="Content-Type: text/plain" \
|
|
54
|
+
--header="X-Container-Name: $CONTAINER_NAME" \
|
|
55
|
+
--header="X-Container-Path: $file" \
|
|
56
|
+
"$NOCTRACE_URL/api/docker/stream" 2>/dev/null || true
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
post_heartbeat() {
|
|
61
|
+
if [ "$HTTP_CMD" = "curl" ]; then
|
|
62
|
+
curl -s -X POST "$NOCTRACE_URL/api/docker/heartbeat" \
|
|
63
|
+
-H "X-Container-Name: $CONTAINER_NAME" || true
|
|
64
|
+
else
|
|
65
|
+
wget -q -O /dev/null --post-data="" \
|
|
66
|
+
--header="X-Container-Name: $CONTAINER_NAME" \
|
|
67
|
+
"$NOCTRACE_URL/api/docker/heartbeat" 2>/dev/null || true
|
|
68
|
+
fi
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
watch_file() {
|
|
72
|
+
file="$1"
|
|
73
|
+
# Send full file content then follow new lines
|
|
74
|
+
tail -f -n +1 "$file" 2>/dev/null | while IFS= read -r line; do
|
|
75
|
+
printf '%s\n' "$line" | post_stream "$file"
|
|
76
|
+
done &
|
|
77
|
+
echo $! >> "$PIDS"
|
|
78
|
+
echo "$file" >> "$WATCHED"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Initial scan — watch all existing JSONL files
|
|
82
|
+
if [ -d "$PROJECTS_DIR" ]; then
|
|
83
|
+
find "$PROJECTS_DIR" -name "*.jsonl" -type f 2>/dev/null | while IFS= read -r f; do
|
|
84
|
+
watch_file "$f"
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Main loop: scan for new files + send heartbeat
|
|
89
|
+
HEARTBEAT_COUNTER=0
|
|
90
|
+
while true; do
|
|
91
|
+
sleep 3
|
|
92
|
+
|
|
93
|
+
# Scan for new JSONL files (including subagents)
|
|
94
|
+
if [ -d "$PROJECTS_DIR" ]; then
|
|
95
|
+
find "$PROJECTS_DIR" -name "*.jsonl" -type f 2>/dev/null | while IFS= read -r f; do
|
|
96
|
+
if ! grep -qxF "$f" "$WATCHED" 2>/dev/null; then
|
|
97
|
+
watch_file "$f"
|
|
98
|
+
fi
|
|
99
|
+
done
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Heartbeat every ~10 seconds (3s sleep * 3 iterations)
|
|
103
|
+
HEARTBEAT_COUNTER=$((HEARTBEAT_COUNTER + 1))
|
|
104
|
+
if [ "$HEARTBEAT_COUNTER" -ge 3 ]; then
|
|
105
|
+
post_heartbeat
|
|
106
|
+
HEARTBEAT_COUNTER=0
|
|
107
|
+
fi
|
|
108
|
+
done
|
package/bin/noctrace-mcp.js
CHANGED
|
@@ -20,9 +20,14 @@ import fs from 'node:fs/promises';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
|
|
23
|
-
const VERSION = '0.
|
|
24
|
-
const NOCTRACE_PORT = 4117;
|
|
25
|
-
|
|
23
|
+
const VERSION = '0.9.0';
|
|
24
|
+
const NOCTRACE_PORT = parseInt(process.env.NOCTRACE_PORT ?? '4117', 10);
|
|
25
|
+
|
|
26
|
+
// Validate NOCTRACE_HOST: only allow localhost, 127.0.0.1, and host.docker.internal
|
|
27
|
+
const rawHost = process.env.NOCTRACE_HOST ?? 'localhost';
|
|
28
|
+
const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'host.docker.internal'];
|
|
29
|
+
const NOCTRACE_HOST = ALLOWED_HOSTS.includes(rawHost) ? rawHost : 'localhost';
|
|
30
|
+
const BASE_URL = `http://${NOCTRACE_HOST}:${NOCTRACE_PORT}`;
|
|
26
31
|
|
|
27
32
|
// ---------------------------------------------------------------------------
|
|
28
33
|
// Session path discovery
|
|
@@ -71,26 +76,56 @@ async function newestJsonl(dir) {
|
|
|
71
76
|
return newest;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Translate a container-internal path to the host-side path.
|
|
81
|
+
* Uses NOCTRACE_PATH_MAP env var: "container_prefix:host_prefix"
|
|
82
|
+
* Example: NOCTRACE_PATH_MAP="/root/.claude:/Users/lam/.claude"
|
|
83
|
+
*
|
|
84
|
+
* @param {string} containerPath
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function translatePath(containerPath) {
|
|
88
|
+
const pathMap = process.env.NOCTRACE_PATH_MAP;
|
|
89
|
+
if (!pathMap) return containerPath;
|
|
90
|
+
const sep = pathMap.indexOf(':');
|
|
91
|
+
if (sep === -1) return containerPath;
|
|
92
|
+
const containerPrefix = pathMap.slice(0, sep);
|
|
93
|
+
const hostPrefix = pathMap.slice(sep + 1);
|
|
94
|
+
if (containerPath.startsWith(containerPrefix)) {
|
|
95
|
+
const translated = hostPrefix + containerPath.slice(containerPrefix.length);
|
|
96
|
+
// Validate no path traversal: resolved path must stay under hostPrefix
|
|
97
|
+
const resolved = path.resolve(translated);
|
|
98
|
+
if (!resolved.startsWith(path.resolve(hostPrefix) + path.sep) && resolved !== path.resolve(hostPrefix)) {
|
|
99
|
+
process.stderr.write(`[noctrace-mcp] Path traversal blocked: ${containerPath}\n`);
|
|
100
|
+
return containerPath;
|
|
101
|
+
}
|
|
102
|
+
return translated;
|
|
103
|
+
}
|
|
104
|
+
return containerPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
/**
|
|
75
108
|
* Discover the JSONL session path for the current Claude Code session.
|
|
109
|
+
* Returns the host-translated path when NOCTRACE_PATH_MAP is set.
|
|
76
110
|
*
|
|
77
111
|
* @returns {Promise<string|null>}
|
|
78
112
|
*/
|
|
79
113
|
async function discoverSessionPath() {
|
|
80
114
|
// Option a: direct env var
|
|
81
115
|
if (process.env.CLAUDE_SESSION_PATH) {
|
|
82
|
-
return process.env.CLAUDE_SESSION_PATH;
|
|
116
|
+
return translatePath(process.env.CLAUDE_SESSION_PATH);
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
// Option b: derive from project directory
|
|
86
120
|
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.env.PWD ?? null;
|
|
87
121
|
if (!projectDir) return null;
|
|
88
122
|
|
|
89
|
-
const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
123
|
+
const claudeHome = process.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
|
|
90
124
|
const slug = pathToSlug(projectDir);
|
|
91
125
|
const projectSessionDir = path.join(claudeHome, 'projects', slug);
|
|
92
126
|
|
|
93
|
-
|
|
127
|
+
const sessionPath = await newestJsonl(projectSessionDir);
|
|
128
|
+
return sessionPath ? translatePath(sessionPath) : null;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
// ---------------------------------------------------------------------------
|
|
@@ -149,7 +184,7 @@ async function postSessionAction(action, sessionPath) {
|
|
|
149
184
|
return new Promise((resolve) => {
|
|
150
185
|
const req = http.request(
|
|
151
186
|
{
|
|
152
|
-
hostname:
|
|
187
|
+
hostname: NOCTRACE_HOST,
|
|
153
188
|
port: NOCTRACE_PORT,
|
|
154
189
|
path: `/api/sessions/${action}`,
|
|
155
190
|
method: 'POST',
|
|
@@ -254,10 +289,14 @@ function handleMessage(line) {
|
|
|
254
289
|
async function main() {
|
|
255
290
|
let sessionPath = await discoverSessionPath();
|
|
256
291
|
|
|
292
|
+
// When running inside Docker (NOCTRACE_HOST != localhost), skip server start —
|
|
293
|
+
// the noctrace server runs on the host, not inside the container.
|
|
294
|
+
const isRemote = NOCTRACE_HOST !== 'localhost' && NOCTRACE_HOST !== '127.0.0.1';
|
|
295
|
+
|
|
257
296
|
// Check if noctrace is already running; if not, start it (first MCP process wins).
|
|
258
297
|
// Use a retry loop to handle the race where two MCP processes start simultaneously.
|
|
259
298
|
const running = await isServerRunning();
|
|
260
|
-
if (!running) {
|
|
299
|
+
if (!running && !isRemote) {
|
|
261
300
|
process.stderr.write('[noctrace-mcp] Starting noctrace server...\n');
|
|
262
301
|
try {
|
|
263
302
|
await startNoctraceServer();
|
package/bin/noctrace.js
CHANGED
|
@@ -197,6 +197,129 @@ if (args.includes('--disable')) {
|
|
|
197
197
|
process.exit(0);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
if (args.includes('--docker')) {
|
|
201
|
+
const containerArg = args[args.indexOf('--docker') + 1];
|
|
202
|
+
if (!containerArg || containerArg.startsWith('--')) {
|
|
203
|
+
console.error('[noctrace] Usage: npx noctrace --docker <container-name-or-id>');
|
|
204
|
+
console.error('[noctrace] Example: npx noctrace --docker my-claude-container');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const {
|
|
209
|
+
isValidContainerName,
|
|
210
|
+
assertContainerRunning,
|
|
211
|
+
resolveClaudeDir,
|
|
212
|
+
detectHttpTool,
|
|
213
|
+
resolveHostUrl,
|
|
214
|
+
copyWatcherScript,
|
|
215
|
+
spawnWatcher,
|
|
216
|
+
cleanupWatcher,
|
|
217
|
+
defaultDockerRunner,
|
|
218
|
+
} = await import('../dist/server/server/docker.js');
|
|
219
|
+
|
|
220
|
+
const { readFileSync, writeFileSync, unlinkSync } = await import('node:fs');
|
|
221
|
+
|
|
222
|
+
// Validate container name to prevent command injection
|
|
223
|
+
if (!isValidContainerName(containerArg)) {
|
|
224
|
+
console.error(`[noctrace] Invalid container name: "${containerArg}"`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Verify container exists and is running
|
|
229
|
+
try {
|
|
230
|
+
assertContainerRunning(containerArg, defaultDockerRunner);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`[noctrace] ${err.message}`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Find Claude config dir inside the container
|
|
237
|
+
const claudeDir = resolveClaudeDir(containerArg, defaultDockerRunner);
|
|
238
|
+
console.log(`[noctrace] Connecting to container "${containerArg}" (claude dir: ${claudeDir})`);
|
|
239
|
+
|
|
240
|
+
// Check if curl or wget exists in the container
|
|
241
|
+
const httpTool = detectHttpTool(containerArg, defaultDockerRunner);
|
|
242
|
+
if (httpTool === 'none') {
|
|
243
|
+
console.error('[noctrace] Container has neither curl nor wget. Cannot stream sessions.');
|
|
244
|
+
console.error('[noctrace] Install curl in the container: apt-get install -y curl');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resolve host URL that the container can reach
|
|
249
|
+
const hostUrl = resolveHostUrl(containerArg, defaultDockerRunner);
|
|
250
|
+
|
|
251
|
+
// Start noctrace server on the host
|
|
252
|
+
process.env.NOCTRACE_NO_AUTOSTART = '1';
|
|
253
|
+
process.env.NODE_ENV = 'production';
|
|
254
|
+
const { startServer } = await import('../dist/server/server/index.js');
|
|
255
|
+
const openMod = await import('open');
|
|
256
|
+
const port = await startServer();
|
|
257
|
+
const url = `http://localhost:${port}`;
|
|
258
|
+
const containerTargetUrl = `${hostUrl}:${port}`;
|
|
259
|
+
console.log(`[noctrace] Dashboard: ${url}`);
|
|
260
|
+
console.log(`[noctrace] Container will stream to: ${containerTargetUrl}`);
|
|
261
|
+
await openMod.default(url);
|
|
262
|
+
|
|
263
|
+
// Read the watcher script from the package
|
|
264
|
+
const watcherScript = readFileSync(
|
|
265
|
+
new URL('./docker-watcher.sh', import.meta.url), 'utf8'
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Copy watcher script into the container
|
|
269
|
+
console.log(`[noctrace] Injecting watcher into container...`);
|
|
270
|
+
const tmpScript = path.join(os.tmpdir(), `noctrace-watcher-${Date.now()}.sh`);
|
|
271
|
+
writeFileSync(tmpScript, watcherScript);
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
copyWatcherScript(containerArg, tmpScript, defaultDockerRunner);
|
|
275
|
+
} finally {
|
|
276
|
+
unlinkSync(tmpScript);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Run the watcher in the background inside the container
|
|
280
|
+
spawnWatcher(containerArg, claudeDir, containerTargetUrl, defaultDockerRunner);
|
|
281
|
+
|
|
282
|
+
console.log(`[noctrace] Watcher injected. Streaming sessions in real-time.`);
|
|
283
|
+
console.log(`[noctrace] Press Ctrl+C to stop.`);
|
|
284
|
+
|
|
285
|
+
// Monitor heartbeats
|
|
286
|
+
let lastHeartbeatCheck = Date.now();
|
|
287
|
+
const heartbeatInterval = setInterval(async () => {
|
|
288
|
+
try {
|
|
289
|
+
const { default: http } = await import('node:http');
|
|
290
|
+
const result = await new Promise((resolve) => {
|
|
291
|
+
const req = http.get(`http://localhost:${port}/api/docker/status`, { timeout: 2000 }, (res) => {
|
|
292
|
+
let data = '';
|
|
293
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
294
|
+
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
|
|
295
|
+
});
|
|
296
|
+
req.on('error', () => resolve(null));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (result && result.containers) {
|
|
300
|
+
const container = result.containers.find((c) => c.name === containerArg);
|
|
301
|
+
if (container && container.stale && Date.now() - lastHeartbeatCheck > 30000) {
|
|
302
|
+
console.log(`[noctrace] Warning: No heartbeat from container "${containerArg}" in 30s. It may have stopped.`);
|
|
303
|
+
lastHeartbeatCheck = Date.now();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch { /* heartbeat check is best-effort */ }
|
|
307
|
+
}, 15000);
|
|
308
|
+
|
|
309
|
+
// Cleanup on exit
|
|
310
|
+
process.on('SIGINT', () => {
|
|
311
|
+
console.log('\n[noctrace] Stopping...');
|
|
312
|
+
clearInterval(heartbeatInterval);
|
|
313
|
+
cleanupWatcher(containerArg, defaultDockerRunner);
|
|
314
|
+
console.log('[noctrace] Stopped.');
|
|
315
|
+
process.exit(0);
|
|
316
|
+
});
|
|
317
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
318
|
+
|
|
319
|
+
// Keep process alive
|
|
320
|
+
await new Promise(() => {});
|
|
321
|
+
}
|
|
322
|
+
|
|
200
323
|
if (args.includes('--mcp')) {
|
|
201
324
|
// MCP mode: boot the Express server and speak JSON-RPC over stdio.
|
|
202
325
|
// stdout is the JSON-RPC channel — all logging must go to stderr.
|