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 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
  ![Noctrace waterfall timeline](docs/screenshots/noctrace-waterfall.gif)
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
@@ -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.5.1';
24
- const NOCTRACE_PORT = 4117;
25
- const BASE_URL = `http://localhost:${NOCTRACE_PORT}`;
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
- return newestJsonl(projectSessionDir);
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: 'localhost',
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.