noctrace 0.9.0 → 1.1.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,8 @@ 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 |
146
+ | `--devcontainer <path>` | Resolve the running devcontainer for a local folder path and attach to it. Pass `.` for the current directory. Falls back to `--docker` if you pass a container name directly |
144
147
  | `--install-hooks` | Configure Claude Code to push real-time events to noctrace |
145
148
  | `--uninstall-hooks` | Remove noctrace hooks from Claude Code |
146
149
 
@@ -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,164 @@ if (args.includes('--disable')) {
197
197
  process.exit(0);
198
198
  }
199
199
 
200
+ /**
201
+ * Run Docker watcher mode with a known, already-validated container ID.
202
+ * Shared by --docker and --devcontainer.
203
+ */
204
+ async function runDockerMode(containerArg) {
205
+ const {
206
+ isValidContainerName,
207
+ assertContainerRunning,
208
+ resolveClaudeDir,
209
+ detectHttpTool,
210
+ resolveHostUrl,
211
+ copyWatcherScript,
212
+ spawnWatcher,
213
+ cleanupWatcher,
214
+ defaultDockerRunner,
215
+ } = await import('../dist/server/server/docker.js');
216
+
217
+ const { readFileSync, writeFileSync, unlinkSync } = await import('node:fs');
218
+
219
+ // Validate container name to prevent command injection
220
+ if (!isValidContainerName(containerArg)) {
221
+ console.error(`[noctrace] Invalid container name: "${containerArg}"`);
222
+ process.exit(1);
223
+ }
224
+
225
+ // Verify container exists and is running
226
+ try {
227
+ assertContainerRunning(containerArg, defaultDockerRunner);
228
+ } catch (err) {
229
+ console.error(`[noctrace] ${err.message}`);
230
+ process.exit(1);
231
+ }
232
+
233
+ // Find Claude config dir inside the container
234
+ const claudeDir = resolveClaudeDir(containerArg, defaultDockerRunner);
235
+ console.log(`[noctrace] Connecting to container "${containerArg}" (claude dir: ${claudeDir})`);
236
+
237
+ // Check if curl or wget exists in the container
238
+ const httpTool = detectHttpTool(containerArg, defaultDockerRunner);
239
+ if (httpTool === 'none') {
240
+ console.error('[noctrace] Container has neither curl nor wget. Cannot stream sessions.');
241
+ console.error('[noctrace] Install curl in the container: apt-get install -y curl');
242
+ process.exit(1);
243
+ }
244
+
245
+ // Resolve host URL that the container can reach
246
+ const hostUrl = resolveHostUrl(containerArg, defaultDockerRunner);
247
+
248
+ // Start noctrace server on the host
249
+ process.env.NOCTRACE_NO_AUTOSTART = '1';
250
+ process.env.NODE_ENV = 'production';
251
+ const { startServer } = await import('../dist/server/server/index.js');
252
+ const openMod = await import('open');
253
+ const port = await startServer();
254
+ const url = `http://localhost:${port}`;
255
+ const containerTargetUrl = `${hostUrl}:${port}`;
256
+ console.log(`[noctrace] Dashboard: ${url}`);
257
+ console.log(`[noctrace] Container will stream to: ${containerTargetUrl}`);
258
+ await openMod.default(url);
259
+
260
+ // Read the watcher script from the package
261
+ const watcherScript = readFileSync(
262
+ new URL('./docker-watcher.sh', import.meta.url), 'utf8'
263
+ );
264
+
265
+ // Copy watcher script into the container
266
+ console.log(`[noctrace] Injecting watcher into container...`);
267
+ const tmpScript = path.join(os.tmpdir(), `noctrace-watcher-${Date.now()}.sh`);
268
+ writeFileSync(tmpScript, watcherScript);
269
+
270
+ try {
271
+ copyWatcherScript(containerArg, tmpScript, defaultDockerRunner);
272
+ } finally {
273
+ unlinkSync(tmpScript);
274
+ }
275
+
276
+ // Run the watcher in the background inside the container
277
+ spawnWatcher(containerArg, claudeDir, containerTargetUrl, defaultDockerRunner);
278
+
279
+ console.log(`[noctrace] Watcher injected. Streaming sessions in real-time.`);
280
+ console.log(`[noctrace] Press Ctrl+C to stop.`);
281
+
282
+ // Monitor heartbeats
283
+ let lastHeartbeatCheck = Date.now();
284
+ const heartbeatInterval = setInterval(async () => {
285
+ try {
286
+ const { default: http } = await import('node:http');
287
+ const result = await new Promise((resolve) => {
288
+ const req = http.get(`http://localhost:${port}/api/docker/status`, { timeout: 2000 }, (res) => {
289
+ let data = '';
290
+ res.on('data', (chunk) => { data += chunk; });
291
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
292
+ });
293
+ req.on('error', () => resolve(null));
294
+ });
295
+
296
+ if (result && result.containers) {
297
+ const container = result.containers.find((c) => c.name === containerArg);
298
+ if (container && container.stale && Date.now() - lastHeartbeatCheck > 30000) {
299
+ console.log(`[noctrace] Warning: No heartbeat from container "${containerArg}" in 30s. It may have stopped.`);
300
+ lastHeartbeatCheck = Date.now();
301
+ }
302
+ }
303
+ } catch { /* heartbeat check is best-effort */ }
304
+ }, 15000);
305
+
306
+ // Cleanup on exit
307
+ process.on('SIGINT', () => {
308
+ console.log('\n[noctrace] Stopping...');
309
+ clearInterval(heartbeatInterval);
310
+ cleanupWatcher(containerArg, defaultDockerRunner);
311
+ console.log('[noctrace] Stopped.');
312
+ process.exit(0);
313
+ });
314
+ process.on('SIGTERM', () => process.exit(0));
315
+
316
+ // Keep process alive
317
+ await new Promise(() => {});
318
+ }
319
+
320
+ if (args.includes('--docker')) {
321
+ const containerArg = args[args.indexOf('--docker') + 1];
322
+ if (!containerArg || containerArg.startsWith('--')) {
323
+ console.error('[noctrace] Usage: npx noctrace --docker <container-name-or-id>');
324
+ console.error('[noctrace] Example: npx noctrace --docker my-claude-container');
325
+ process.exit(1);
326
+ }
327
+ await runDockerMode(containerArg);
328
+ }
329
+
330
+ if (args.includes('--devcontainer')) {
331
+ const devcontainerArg = args[args.indexOf('--devcontainer') + 1];
332
+ if (!devcontainerArg || devcontainerArg.startsWith('--')) {
333
+ console.error('[noctrace] Usage: npx noctrace --devcontainer <path-or-container>');
334
+ console.error('[noctrace] Examples:');
335
+ console.error('[noctrace] npx noctrace --devcontainer .');
336
+ console.error('[noctrace] npx noctrace --devcontainer /Users/me/myproject');
337
+ console.error('[noctrace] npx noctrace --devcontainer my-devcontainer-id');
338
+ process.exit(1);
339
+ }
340
+
341
+ const { resolveDevcontainerContainer, defaultDockerRunner } =
342
+ await import('../dist/server/server/docker.js');
343
+
344
+ let resolvedContainer;
345
+ try {
346
+ resolvedContainer = resolveDevcontainerContainer(devcontainerArg, defaultDockerRunner);
347
+ } catch (err) {
348
+ const lines = err.message.split('\n');
349
+ for (const line of lines) {
350
+ console.error(`[noctrace] ${line}`);
351
+ }
352
+ process.exit(1);
353
+ }
354
+
355
+ await runDockerMode(resolvedContainer);
356
+ }
357
+
200
358
  if (args.includes('--mcp')) {
201
359
  // MCP mode: boot the Express server and speak JSON-RPC over stdio.
202
360
  // stdout is the JSON-RPC channel — all logging must go to stderr.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Docker support for noctrace.
3
+ *
4
+ * Orchestrates container inspection, HTTP-tool detection, host URL resolution,
5
+ * watcher injection, and cleanup. All Docker commands go through the
6
+ * DockerRunner interface so callers (and tests) can swap in a stub.
7
+ */
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ // ---------------------------------------------------------------------------
11
+ // Default runner (real child_process)
12
+ // ---------------------------------------------------------------------------
13
+ import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
14
+ export const defaultDockerRunner = {
15
+ execSync(cmd, args, opts = {}) {
16
+ return execFileSync(cmd, args, { stdio: opts.stdio ?? 'pipe', timeout: opts.timeout })
17
+ .toString();
18
+ },
19
+ spawn(cmd, args, opts = {}) {
20
+ return nodeSpawn(cmd, args, opts);
21
+ },
22
+ };
23
+ // ---------------------------------------------------------------------------
24
+ // Validation
25
+ // ---------------------------------------------------------------------------
26
+ const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
27
+ /**
28
+ * Returns true when the container name is syntactically safe to pass as an
29
+ * argv element. Rejects names that could be used for command injection or
30
+ * path traversal.
31
+ */
32
+ export function isValidContainerName(name) {
33
+ return CONTAINER_NAME_RE.test(name);
34
+ }
35
+ /**
36
+ * Returns true when a path is free of directory-traversal sequences.
37
+ * Any segment equal to `..` is rejected regardless of surrounding context.
38
+ */
39
+ export function isValidContainerPath(p) {
40
+ return !p.split('/').includes('..');
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Container state
44
+ // ---------------------------------------------------------------------------
45
+ /**
46
+ * Verify the container is running. Throws with a user-friendly message when
47
+ * the container is not found or not running.
48
+ */
49
+ export function assertContainerRunning(containerArg, runner) {
50
+ try {
51
+ runner.execSync('docker', ['inspect', '--format', '{{.State.Running}}', containerArg], { stdio: 'pipe' });
52
+ }
53
+ catch {
54
+ throw new Error(`Container "${containerArg}" not found or not running.\nCheck: docker ps`);
55
+ }
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Claude config dir inside container
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * Ask the container for the Claude config directory (respects CLAUDE_CONFIG_DIR).
62
+ */
63
+ export function resolveClaudeDir(containerArg, runner) {
64
+ return runner
65
+ .execSync('docker', ['exec', containerArg, 'sh', '-c', 'echo ${CLAUDE_CONFIG_DIR:-$HOME/.claude}'], { stdio: 'pipe' })
66
+ .trim();
67
+ }
68
+ /**
69
+ * Determine which HTTP client is available inside the container.
70
+ * Tries curl first, falls back to wget, returns 'none' if neither is present.
71
+ */
72
+ export function detectHttpTool(containerArg, runner) {
73
+ try {
74
+ runner.execSync('docker', ['exec', containerArg, 'which', 'curl'], { stdio: 'pipe' });
75
+ return 'curl';
76
+ }
77
+ catch { /* curl not found */ }
78
+ try {
79
+ runner.execSync('docker', ['exec', containerArg, 'which', 'wget'], { stdio: 'pipe' });
80
+ return 'wget';
81
+ }
82
+ catch { /* wget not found */ }
83
+ return 'none';
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Host URL resolution
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Resolve the URL the container can use to reach the host.
90
+ *
91
+ * Priority:
92
+ * 1. `host.docker.internal` (works on macOS/Windows Docker Desktop)
93
+ * 2. Gateway IP from `docker inspect` (Linux Docker)
94
+ * 3. Fallback to `host.docker.internal` (best-effort)
95
+ */
96
+ export function resolveHostUrl(containerArg, runner) {
97
+ try {
98
+ runner.execSync('docker', ['exec', containerArg, 'getent', 'hosts', 'host.docker.internal'], { stdio: 'pipe' });
99
+ return 'http://host.docker.internal';
100
+ }
101
+ catch { /* not available — try gateway IP */ }
102
+ try {
103
+ const gatewayIp = runner
104
+ .execSync('docker', [
105
+ 'inspect',
106
+ '--format',
107
+ '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}',
108
+ containerArg,
109
+ ], { stdio: 'pipe' })
110
+ .trim();
111
+ if (gatewayIp) {
112
+ return `http://${gatewayIp}`;
113
+ }
114
+ }
115
+ catch { /* ignore */ }
116
+ return 'http://host.docker.internal';
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Watcher injection
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Copy a local script file into the container at `/tmp/noctrace-watcher.sh`,
123
+ * mark it executable, and return. The caller is responsible for running it.
124
+ */
125
+ export function copyWatcherScript(containerArg, localScriptPath, runner) {
126
+ runner.execSync('docker', ['cp', localScriptPath, `${containerArg}:/tmp/noctrace-watcher.sh`], { stdio: 'pipe' });
127
+ runner.execSync('docker', ['exec', containerArg, 'chmod', '+x', '/tmp/noctrace-watcher.sh'], { stdio: 'pipe' });
128
+ }
129
+ /**
130
+ * Start the injected watcher script inside the container in the background.
131
+ * Returns the spawned process handle (callers can swallow its errors).
132
+ */
133
+ export function spawnWatcher(containerArg, claudeDir, containerTargetUrl, runner) {
134
+ const proc = runner.spawn('docker', [
135
+ 'exec', '-d', containerArg,
136
+ 'sh', '-c', '/tmp/noctrace-watcher.sh "$1" "$2" "$3"', '--',
137
+ claudeDir, containerTargetUrl, containerArg,
138
+ ], { stdio: 'ignore' });
139
+ proc.on('error', () => { });
140
+ return proc;
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Cleanup
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Kill the noctrace-watcher process inside the container.
147
+ * Safe to call after container exit — errors are swallowed.
148
+ */
149
+ export function cleanupWatcher(containerArg, runner) {
150
+ try {
151
+ runner.execSync('docker', ['exec', containerArg, 'sh', '-c', 'pkill -f noctrace-watcher 2>/dev/null || true'], { stdio: 'pipe', timeout: 3000 });
152
+ }
153
+ catch { /* container may be gone */ }
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Devcontainer support
157
+ // ---------------------------------------------------------------------------
158
+ /**
159
+ * Look up a running container by an exact Docker label match.
160
+ * Returns the container ID (short form), or null when nothing matches.
161
+ *
162
+ * Uses `docker ps --filter "label=<label>=<value>" --format "{{.ID}}"`.
163
+ * The label and value are passed as a single `label=key=value` filter argument
164
+ * so no shell interpolation occurs.
165
+ */
166
+ export function findContainerByLabel(label, value, runner) {
167
+ let output;
168
+ try {
169
+ output = runner.execSync('docker', ['ps', '--filter', `label=${label}=${value}`, '--format', '{{.ID}}'], { stdio: 'pipe' });
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ const id = output.trim().split('\n')[0]?.trim() ?? '';
175
+ return id.length > 0 ? id : null;
176
+ }
177
+ /**
178
+ * Resolve a devcontainer argument to a concrete container ID.
179
+ *
180
+ * If `input` looks like a path (starts with `/`, `.`, `./`, or `~/`) it is
181
+ * resolved to an absolute path and looked up via the canonical
182
+ * `devcontainer.local_folder` label, falling back to the older
183
+ * `vsch.local.folder` label. When neither label matches an error is thrown
184
+ * with a clear hint pointing the user at `docker ps --filter "label=devcontainer.*"`.
185
+ *
186
+ * If `input` is not a path it is treated as a container name/ID.
187
+ * `isValidContainerName` is checked and the value is returned directly.
188
+ *
189
+ * @param cwd - Working directory used to resolve relative paths. Defaults to `process.cwd()`.
190
+ */
191
+ export function resolveDevcontainerContainer(input, runner, cwd) {
192
+ const isPath = input.startsWith('/') || input.startsWith('.') || input.startsWith('~/');
193
+ if (!isPath) {
194
+ if (!isValidContainerName(input)) {
195
+ throw new Error(`Invalid container name: "${input}"`);
196
+ }
197
+ return input;
198
+ }
199
+ // Resolve to an absolute path — devcontainer labels always store absolute paths.
200
+ // path.resolve does not expand ~ so handle that explicitly.
201
+ let absPath;
202
+ if (input.startsWith('~/')) {
203
+ absPath = path.join(os.homedir(), input.slice(2));
204
+ }
205
+ else {
206
+ absPath = path.resolve(cwd ?? process.cwd(), input);
207
+ }
208
+ // Try canonical label first, then the older VS Code label.
209
+ const id = findContainerByLabel('devcontainer.local_folder', absPath, runner) ??
210
+ findContainerByLabel('vsch.local.folder', absPath, runner);
211
+ if (id === null) {
212
+ throw new Error(`No devcontainer found for path: ${absPath}\n` +
213
+ `Hint: make sure the devcontainer is running, then check:\n` +
214
+ ` docker ps --filter "label=devcontainer.local_folder"`);
215
+ }
216
+ return id;
217
+ }
@@ -2,7 +2,7 @@
2
2
  * REST API routes for project and session data.
3
3
  * All data is read from JSONL files on disk — no in-memory caching.
4
4
  */
5
- import { Router } from 'express';
5
+ import express, { Router } from 'express';
6
6
  import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { WebSocket } from 'ws';
@@ -76,6 +76,8 @@ export function buildApiRouter(claudeHome, wss) {
76
76
  * When non-empty the client operates in "MCP mode" and shows only these sessions.
77
77
  */
78
78
  const registeredSessionPaths = new Set();
79
+ /** Last heartbeat timestamp from Docker container watchers, keyed by container name. */
80
+ const dockerHeartbeats = new Map();
79
81
  /** Broadcast a message to all connected WebSocket clients. */
80
82
  function broadcast(msg) {
81
83
  const payload = JSON.stringify(msg);
@@ -543,6 +545,103 @@ export function buildApiRouter(claudeHome, wss) {
543
545
  }
544
546
  });
545
547
  // ---------------------------------------------------------------------------
548
+ // POST /api/docker/stream
549
+ // ---------------------------------------------------------------------------
550
+ /**
551
+ * Receive streamed JSONL content from a Docker container watcher.
552
+ * Appends raw text to a local sync file under the projects directory.
553
+ * Chokidar picks up the file change and handles parsing + WebSocket broadcasting.
554
+ */
555
+ router.post('/docker/stream', express.text({ type: 'text/plain', limit: '1mb' }), async (req, res) => {
556
+ try {
557
+ const containerName = req.headers['x-container-name'];
558
+ const containerPath = req.headers['x-container-path'];
559
+ if (typeof containerName !== 'string' || !containerName) {
560
+ res.status(400).json({ error: 'X-Container-Name header required' });
561
+ return;
562
+ }
563
+ if (typeof containerPath !== 'string' || !containerPath) {
564
+ res.status(400).json({ error: 'X-Container-Path header required' });
565
+ return;
566
+ }
567
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerName)) {
568
+ res.status(400).json({ error: 'Invalid container name format' });
569
+ return;
570
+ }
571
+ const body = req.body;
572
+ if (!body || !body.trim()) {
573
+ res.status(400).json({ error: 'Empty body' });
574
+ return;
575
+ }
576
+ // Extract relative path after /projects/
577
+ const projectsIdx = containerPath.indexOf('/projects/');
578
+ if (projectsIdx === -1) {
579
+ res.status(400).json({ error: 'Container path must contain /projects/' });
580
+ return;
581
+ }
582
+ const relativePath = containerPath.slice(projectsIdx + '/projects/'.length);
583
+ // Reject path traversal patterns in the relative path
584
+ if (relativePath.includes('..')) {
585
+ res.status(400).json({ error: 'Invalid path' });
586
+ return;
587
+ }
588
+ const slashIdx = relativePath.indexOf('/');
589
+ if (slashIdx === -1) {
590
+ res.status(400).json({ error: 'Invalid container path structure' });
591
+ return;
592
+ }
593
+ const containerSlug = relativePath.slice(0, slashIdx);
594
+ const sessionFile = relativePath.slice(slashIdx + 1);
595
+ const localSlug = `docker--${containerName}--${containerSlug}`;
596
+ const localPath = path.join(projectsDir, localSlug, sessionFile);
597
+ try {
598
+ assertWithinBase(localPath, projectsDir);
599
+ }
600
+ catch {
601
+ res.status(400).json({ error: 'Invalid path' });
602
+ return;
603
+ }
604
+ await fs.mkdir(path.dirname(localPath), { recursive: true });
605
+ await fs.appendFile(localPath, body.endsWith('\n') ? body : body + '\n');
606
+ // Auto-register the session
607
+ if (localPath.endsWith('.jsonl') && !registeredSessionPaths.has(localPath)) {
608
+ const resolvedPath = path.resolve(localPath);
609
+ registeredSessionPaths.add(resolvedPath);
610
+ broadcast({ type: 'session-registered', sessionPath: resolvedPath });
611
+ }
612
+ res.json({ ok: true });
613
+ }
614
+ catch (err) {
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ res.status(500).json({ error: message });
617
+ }
618
+ });
619
+ // ---------------------------------------------------------------------------
620
+ // POST /api/docker/heartbeat
621
+ // ---------------------------------------------------------------------------
622
+ /** Keepalive endpoint for Docker container watchers. */
623
+ router.post('/docker/heartbeat', (req, res) => {
624
+ const containerName = req.headers['x-container-name'];
625
+ if (typeof containerName !== 'string' || !containerName) {
626
+ res.status(400).json({ error: 'X-Container-Name header required' });
627
+ return;
628
+ }
629
+ dockerHeartbeats.set(containerName, Date.now());
630
+ res.json({ ok: true });
631
+ });
632
+ // ---------------------------------------------------------------------------
633
+ // GET /api/docker/status
634
+ // ---------------------------------------------------------------------------
635
+ /** Returns the status of connected Docker containers. */
636
+ router.get('/docker/status', (_req, res) => {
637
+ const containers = [];
638
+ const now = Date.now();
639
+ for (const [name, ts] of dockerHeartbeats) {
640
+ containers.push({ name, lastHeartbeat: ts, stale: now - ts > 30_000 });
641
+ }
642
+ res.json({ containers });
643
+ });
644
+ // ---------------------------------------------------------------------------
546
645
  // POST /api/hooks
547
646
  // ---------------------------------------------------------------------------
548
647
  /**
package/hooks/hooks.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "hooks": [
5
5
  {
6
6
  "type": "command",
7
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
7
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
8
8
  "async": true
9
9
  }
10
10
  ]
@@ -15,7 +15,7 @@
15
15
  "hooks": [
16
16
  {
17
17
  "type": "command",
18
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
18
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
19
19
  "async": true
20
20
  }
21
21
  ]
@@ -26,7 +26,7 @@
26
26
  "hooks": [
27
27
  {
28
28
  "type": "command",
29
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
29
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
30
30
  "async": true
31
31
  }
32
32
  ]
@@ -37,7 +37,7 @@
37
37
  "hooks": [
38
38
  {
39
39
  "type": "command",
40
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
40
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
41
41
  "async": true
42
42
  }
43
43
  ]
@@ -48,7 +48,7 @@
48
48
  "hooks": [
49
49
  {
50
50
  "type": "command",
51
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
51
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
52
52
  "async": true
53
53
  }
54
54
  ]
@@ -59,7 +59,7 @@
59
59
  "hooks": [
60
60
  {
61
61
  "type": "command",
62
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
62
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
63
63
  "async": true
64
64
  }
65
65
  ]
@@ -70,7 +70,7 @@
70
70
  "hooks": [
71
71
  {
72
72
  "type": "command",
73
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
73
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
74
74
  "async": true
75
75
  }
76
76
  ]
@@ -81,7 +81,7 @@
81
81
  "hooks": [
82
82
  {
83
83
  "type": "command",
84
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
84
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
85
85
  "async": true
86
86
  }
87
87
  ]
@@ -92,7 +92,7 @@
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
95
- "command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
95
+ "command": "curl -s -X POST \"${NOCTRACE_URL:-http://localhost:4117}/api/hooks\" -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
96
96
  "async": true
97
97
  }
98
98
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.9.0",
3
+ "version": "1.1.0",
4
4
  "description": "Claude Code observability — DevTools-style waterfall visualizer for AI agent workflows, token tracking, and context health monitoring",
5
5
  "type": "module",
6
6
  "license": "MIT",