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.
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-B37clQwh.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-D3XepZ5e.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-DwPuae45.css">
12
12
  </head>
13
13
  <body>
@@ -0,0 +1,152 @@
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
+ // ---------------------------------------------------------------------------
9
+ // Default runner (real child_process)
10
+ // ---------------------------------------------------------------------------
11
+ import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
12
+ export const defaultDockerRunner = {
13
+ execSync(cmd, args, opts = {}) {
14
+ return execFileSync(cmd, args, { stdio: opts.stdio ?? 'pipe', timeout: opts.timeout })
15
+ .toString();
16
+ },
17
+ spawn(cmd, args, opts = {}) {
18
+ return nodeSpawn(cmd, args, opts);
19
+ },
20
+ };
21
+ // ---------------------------------------------------------------------------
22
+ // Validation
23
+ // ---------------------------------------------------------------------------
24
+ const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
25
+ /**
26
+ * Returns true when the container name is syntactically safe to pass as an
27
+ * argv element. Rejects names that could be used for command injection or
28
+ * path traversal.
29
+ */
30
+ export function isValidContainerName(name) {
31
+ return CONTAINER_NAME_RE.test(name);
32
+ }
33
+ /**
34
+ * Returns true when a path is free of directory-traversal sequences.
35
+ * Any segment equal to `..` is rejected regardless of surrounding context.
36
+ */
37
+ export function isValidContainerPath(p) {
38
+ return !p.split('/').includes('..');
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Container state
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Verify the container is running. Throws with a user-friendly message when
45
+ * the container is not found or not running.
46
+ */
47
+ export function assertContainerRunning(containerArg, runner) {
48
+ try {
49
+ runner.execSync('docker', ['inspect', '--format', '{{.State.Running}}', containerArg], { stdio: 'pipe' });
50
+ }
51
+ catch {
52
+ throw new Error(`Container "${containerArg}" not found or not running.\nCheck: docker ps`);
53
+ }
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Claude config dir inside container
57
+ // ---------------------------------------------------------------------------
58
+ /**
59
+ * Ask the container for the Claude config directory (respects CLAUDE_CONFIG_DIR).
60
+ */
61
+ export function resolveClaudeDir(containerArg, runner) {
62
+ return runner
63
+ .execSync('docker', ['exec', containerArg, 'sh', '-c', 'echo ${CLAUDE_CONFIG_DIR:-$HOME/.claude}'], { stdio: 'pipe' })
64
+ .trim();
65
+ }
66
+ /**
67
+ * Determine which HTTP client is available inside the container.
68
+ * Tries curl first, falls back to wget, returns 'none' if neither is present.
69
+ */
70
+ export function detectHttpTool(containerArg, runner) {
71
+ try {
72
+ runner.execSync('docker', ['exec', containerArg, 'which', 'curl'], { stdio: 'pipe' });
73
+ return 'curl';
74
+ }
75
+ catch { /* curl not found */ }
76
+ try {
77
+ runner.execSync('docker', ['exec', containerArg, 'which', 'wget'], { stdio: 'pipe' });
78
+ return 'wget';
79
+ }
80
+ catch { /* wget not found */ }
81
+ return 'none';
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Host URL resolution
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Resolve the URL the container can use to reach the host.
88
+ *
89
+ * Priority:
90
+ * 1. `host.docker.internal` (works on macOS/Windows Docker Desktop)
91
+ * 2. Gateway IP from `docker inspect` (Linux Docker)
92
+ * 3. Fallback to `host.docker.internal` (best-effort)
93
+ */
94
+ export function resolveHostUrl(containerArg, runner) {
95
+ try {
96
+ runner.execSync('docker', ['exec', containerArg, 'getent', 'hosts', 'host.docker.internal'], { stdio: 'pipe' });
97
+ return 'http://host.docker.internal';
98
+ }
99
+ catch { /* not available — try gateway IP */ }
100
+ try {
101
+ const gatewayIp = runner
102
+ .execSync('docker', [
103
+ 'inspect',
104
+ '--format',
105
+ '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}',
106
+ containerArg,
107
+ ], { stdio: 'pipe' })
108
+ .trim();
109
+ if (gatewayIp) {
110
+ return `http://${gatewayIp}`;
111
+ }
112
+ }
113
+ catch { /* ignore */ }
114
+ return 'http://host.docker.internal';
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Watcher injection
118
+ // ---------------------------------------------------------------------------
119
+ /**
120
+ * Copy a local script file into the container at `/tmp/noctrace-watcher.sh`,
121
+ * mark it executable, and return. The caller is responsible for running it.
122
+ */
123
+ export function copyWatcherScript(containerArg, localScriptPath, runner) {
124
+ runner.execSync('docker', ['cp', localScriptPath, `${containerArg}:/tmp/noctrace-watcher.sh`], { stdio: 'pipe' });
125
+ runner.execSync('docker', ['exec', containerArg, 'chmod', '+x', '/tmp/noctrace-watcher.sh'], { stdio: 'pipe' });
126
+ }
127
+ /**
128
+ * Start the injected watcher script inside the container in the background.
129
+ * Returns the spawned process handle (callers can swallow its errors).
130
+ */
131
+ export function spawnWatcher(containerArg, claudeDir, containerTargetUrl, runner) {
132
+ const proc = runner.spawn('docker', [
133
+ 'exec', '-d', containerArg,
134
+ 'sh', '-c', '/tmp/noctrace-watcher.sh "$1" "$2" "$3"', '--',
135
+ claudeDir, containerTargetUrl, containerArg,
136
+ ], { stdio: 'ignore' });
137
+ proc.on('error', () => { });
138
+ return proc;
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Cleanup
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * Kill the noctrace-watcher process inside the container.
145
+ * Safe to call after container exit — errors are swallowed.
146
+ */
147
+ export function cleanupWatcher(containerArg, runner) {
148
+ try {
149
+ runner.execSync('docker', ['exec', containerArg, 'sh', '-c', 'pkill -f noctrace-watcher 2>/dev/null || true'], { stdio: 'pipe', timeout: 3000 });
150
+ }
151
+ catch { /* container may be gone */ }
152
+ }
@@ -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
  /**
@@ -67,6 +67,11 @@ export function parseFilterString(filter) {
67
67
  result.typeFilters.push('agent');
68
68
  continue;
69
69
  }
70
+ // 'turn' keyword: treated as type filter
71
+ if (lower === 'turn') {
72
+ result.typeFilters.push('turn');
73
+ continue;
74
+ }
70
75
  // Everything else is free text
71
76
  result.textTokens.push(lower);
72
77
  }
@@ -636,6 +636,106 @@ export function parseJsonlContent(content) {
636
636
  });
637
637
  }
638
638
  }
639
+ // Create turn rows for user prompts (string content = human text, not tool results)
640
+ for (const rec of records) {
641
+ if (rec.type !== 'user')
642
+ continue;
643
+ const ur = rec;
644
+ // Array content means tool_result records — skip
645
+ if (Array.isArray(ur.message.content))
646
+ continue;
647
+ if (ur.isMeta === true)
648
+ continue;
649
+ const raw = rec;
650
+ if (raw['isSynthetic'] === true)
651
+ continue;
652
+ const text = typeof ur.message.content === 'string' ? ur.message.content : '';
653
+ if (!text.trim())
654
+ continue;
655
+ const ts = new Date(ur.timestamp).getTime();
656
+ const truncated = text.length > 120 ? text.slice(0, 117) + '...' : text;
657
+ top.push({
658
+ id: `turn-user-${ur.uuid}`,
659
+ type: 'turn',
660
+ toolName: 'UserPrompt',
661
+ label: truncated,
662
+ startTime: ts,
663
+ endTime: ts,
664
+ duration: 0,
665
+ status: 'success',
666
+ parentAgentId: null,
667
+ input: {},
668
+ output: text,
669
+ inputTokens: 0,
670
+ outputTokens: 0,
671
+ tokenDelta: 0,
672
+ contextFillPercent: 0,
673
+ isReread: false,
674
+ isFailure: false,
675
+ children: [],
676
+ tips: [],
677
+ modelName: null,
678
+ estimatedCost: null,
679
+ agentType: null,
680
+ agentColor: null,
681
+ sequence: null,
682
+ isFastMode: false,
683
+ parentToolUseId: null,
684
+ });
685
+ }
686
+ // Create turn rows for assistant text-only responses (no tool_use blocks)
687
+ for (const rec of records) {
688
+ if (rec.type !== 'assistant')
689
+ continue;
690
+ const ar = rec;
691
+ const hasToolUse = ar.message.content.some(b => isToolUse(b));
692
+ if (hasToolUse)
693
+ continue; // already handled by the tool row creation loop
694
+ if (ar.message.error)
695
+ continue; // already handled by api-error loop
696
+ const texts = ar.message.content
697
+ .filter((b) => b.type === 'text')
698
+ .map(b => b.text);
699
+ const fullText = texts.join('\n');
700
+ if (!fullText.trim())
701
+ continue;
702
+ const ts = new Date(ar.timestamp).getTime();
703
+ const truncated = fullText.length > 120 ? fullText.slice(0, 117) + '...' : fullText;
704
+ const usage = ar.message.usage;
705
+ const inputTokens = (usage?.input_tokens ?? 0)
706
+ + (usage?.cache_creation_input_tokens ?? 0)
707
+ + (usage?.cache_read_input_tokens ?? 0);
708
+ const outputTokens = usage?.output_tokens ?? 0;
709
+ const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
710
+ top.push({
711
+ id: `turn-asst-${ar.uuid}`,
712
+ type: 'turn',
713
+ toolName: 'AssistantResponse',
714
+ label: truncated,
715
+ startTime: ts,
716
+ endTime: ts,
717
+ duration: 0,
718
+ status: 'success',
719
+ parentAgentId: null,
720
+ input: {},
721
+ output: fullText,
722
+ inputTokens,
723
+ outputTokens,
724
+ tokenDelta: 0,
725
+ contextFillPercent: (inputTokens / effectiveWindow) * 100,
726
+ isReread: false,
727
+ isFailure: false,
728
+ children: [],
729
+ tips: [],
730
+ modelName,
731
+ estimatedCost: null,
732
+ agentType: null,
733
+ agentColor: null,
734
+ sequence: typeof ar.sequence === 'number' ? ar.sequence : null,
735
+ isFastMode: ar.message.speed === 'fast',
736
+ parentToolUseId: null,
737
+ });
738
+ }
639
739
  return top;
640
740
  }
641
741
  /**
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.8.2",
3
+ "version": "1.0.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",