claude-code-watch 0.0.10 → 0.0.11

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,6 +7,7 @@ const cp = require('child_process');
7
7
 
8
8
  const { startServer } = require('../src/server/server');
9
9
  const { listSessions, listActiveSessions } = require('../src/watcher/watcher');
10
+ const { compareVersions, parseDuration } = require('../src/cli-helpers');
10
11
 
11
12
  const { version: VERSION } = require('../package.json');
12
13
 
@@ -41,14 +42,8 @@ ENVIRONMENT:
41
42
  `);
42
43
  }
43
44
 
44
- function compareVersions(a, b) {
45
- const pa = a.split('.').map(Number);
46
- const pb = b.split('.').map(Number);
47
- for (let i = 0; i < 3; i++) {
48
- if (pa[i] > pb[i]) return 1;
49
- if (pa[i] < pb[i]) return -1;
50
- }
51
- return 0;
45
+ function printVersion() {
46
+ console.log(`claude-watch v${VERSION}`);
52
47
  }
53
48
 
54
49
  function fetchLatestVersion() {
@@ -128,19 +123,6 @@ async function runUpdate() {
128
123
  }
129
124
  }
130
125
 
131
- function parseDuration(s) {
132
- const match = s.match(/^(\d+)(ms|s|m|h)$/);
133
- if (!match) throw new Error(`Invalid duration: ${s}`);
134
- const val = parseInt(match[1], 10);
135
- switch (match[2]) {
136
- case 'ms': return val;
137
- case 's': return val * 1000;
138
- case 'm': return val * 60 * 1000;
139
- case 'h': return val * 3600 * 1000;
140
- default: throw new Error(`Invalid duration unit: ${match[2]}`);
141
- }
142
- }
143
-
144
126
  async function main() {
145
127
  const args = process.argv.slice(2);
146
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node bin/claude-watch.js",
12
12
  "dev": "node --watch bin/claude-watch.js --no-open",
13
- "test": "node --test tests/all.test.js tests/watcher.test.js tests/server.test.js"
13
+ "test": "node --test tests/all.test.js tests/watcher.test.js tests/server.test.js tests/cli.test.js"
14
14
  },
15
15
  "files": [
16
16
  "bin",
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0%" stop-color="#7c3aed"/>
5
+ <stop offset="100%" stop-color="#4c1d95"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="32" height="32" rx="6" fill="url(#bg)"/>
9
+ <!-- Eye outline - wide almond, flatter top/bottom -->
10
+ <path d="M4 16 C6 11 10 9 16 9 C22 9 26 11 28 16 C26 21 22 23 16 23 C10 23 6 21 4 16Z" fill="#1f2937" stroke="#c084fc" stroke-width="2"/>
11
+ <!-- Iris - horizontal ellipse -->
12
+ <ellipse cx="16" cy="16" rx="6" ry="4.5" fill="#a855f7" stroke="#e9d5ff" stroke-width="1.5"/>
13
+ <!-- Pupil -->
14
+ <ellipse cx="16" cy="16" rx="2.5" ry="2" fill="#0f0a1a"/>
15
+ <!-- Shine -->
16
+ <circle cx="14" cy="14.5" r="1.2" fill="#f9fafb"/>
17
+ </svg>
package/public/index.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>claude-watch</title>
7
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
7
8
  <link rel="stylesheet" href="vendor/github-dark.min.css">
8
9
  <style>
9
10
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -386,7 +387,6 @@ const MAX_ITEMS = 3000;
386
387
  const MAX_LINES = 50;
387
388
  let renderedItemCount = 0;
388
389
  let needsFullRender = true;
389
- visibleDirty = true;
390
390
 
391
391
  // ══════════════════════════════════════════════════════════════════════════════
392
392
  // Markdown renderer (marked + highlight.js)
@@ -588,8 +588,8 @@ function handleNewBgTask(payload) {
588
588
  function handleSessionRemoved(payload) {
589
589
  const idx = sessions.findIndex(s => s.id === payload.sessionID);
590
590
  if (idx >= 0) {
591
- const session = sessions.splice(idx, 1)[0];
592
- sessions.push(session);
591
+ sessions.splice(idx, 1);
592
+ sessionsMap.delete(payload.sessionID);
593
593
  }
594
594
  updateFilters();
595
595
  rebuildNodes();
@@ -733,7 +733,7 @@ function getNodeHTML(node, idx) {
733
733
  return `<div class="tree-row${selClass ? ' selected' : ''}">
734
734
  <div class="tree-node" onclick="treeClick(${idx})" data-idx="${idx}">
735
735
  <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} ${esc(displayName)}
736
- ${node.collapsed && agentCount > 0 ? `(${agentCount})` : ''}
736
+ ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
737
737
  ${subInfo}
738
738
  </div>
739
739
  <span class="tree-actions">
@@ -1112,11 +1112,11 @@ function removeSelectedSession() {
1112
1112
  if (node.type === 'session') sid = node.id;
1113
1113
  else sid = node.sessionID;
1114
1114
  if (!sid) return;
1115
- if (!confirm(`Move session ${sid.slice(0, 12)}... to bottom?`)) return;
1115
+ if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1116
1116
  const idx = sessions.findIndex(s => s.id === sid);
1117
1117
  if (idx >= 0) {
1118
- const session = sessions.splice(idx, 1)[0];
1119
- sessions.push(session);
1118
+ sessions.splice(idx, 1);
1119
+ sessionsMap.delete(sid);
1120
1120
  }
1121
1121
  sendCmd('removeSession', { sessionID: sid });
1122
1122
  updateFilters();
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ function compareVersions(a, b) {
4
+ var pa = a.split('.').map(Number);
5
+ var pb = b.split('.').map(Number);
6
+ for (var i = 0; i < 3; i++) {
7
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
8
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
9
+ }
10
+ return 0;
11
+ }
12
+
13
+ function parseDuration(s) {
14
+ var match = s.match(/^(\d+)(ms|s|m|h)$/);
15
+ if (!match) throw new Error('Invalid duration: ' + s);
16
+ var val = parseInt(match[1], 10);
17
+ switch (match[2]) {
18
+ case 'ms': return val;
19
+ case 's': return val * 1000;
20
+ case 'm': return val * 60 * 1000;
21
+ case 'h': return val * 3600 * 1000;
22
+ default: throw new Error('Invalid duration unit: ' + match[2]);
23
+ }
24
+ }
25
+
26
+ module.exports = { compareVersions, parseDuration };
@@ -43,7 +43,8 @@ function setDebugAll(val) {
43
43
 
44
44
  function agentDisplayName(agentID) {
45
45
  if (!agentID) return 'Main';
46
- return `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
46
+ var id = String(agentID);
47
+ return 'Agent-' + id.slice(0, Math.min(AgentIDDisplayLength, id.length));
47
48
  }
48
49
 
49
50
  // ============================================================================
@@ -281,7 +282,7 @@ function diagnosticsBody(diagnostics) {
281
282
  // ============================================================================
282
283
 
283
284
  function parsePRLink(raw, timestamp) {
284
- if (!raw.prNumber && !raw.prUrl) return [];
285
+ if (raw.prNumber == null && !raw.prUrl) return [];
285
286
  let content;
286
287
  if (raw.prRepository && raw.prUrl) {
287
288
  content = `PR #${raw.prNumber} ${raw.prRepository} \u2192 ${raw.prUrl}`;
@@ -441,8 +442,8 @@ function formatToolInput(toolName, input) {
441
442
  if (inp.path) return `${inp.pattern} in ${inp.path}`;
442
443
  return inp.pattern || '';
443
444
  case 'Grep':
444
- if (inp.path) return `/${inp.pattern}/ in ${inp.path}`;
445
- return `/${inp.pattern}/`;
445
+ if (inp.path) return `/${inp.pattern || ''}/ in ${inp.path}`;
446
+ return `/${inp.pattern || ''}/`;
446
447
  case 'WebFetch':
447
448
  return inp.prompt || '';
448
449
  case 'WebSearch':
@@ -493,4 +494,8 @@ module.exports = {
493
494
  contextWindowFor,
494
495
  formatTokenCount,
495
496
  AgentIDDisplayLength,
497
+ formatToolInput,
498
+ prettyToolName,
499
+ agentDisplayName,
500
+ MAX_TOOL_INPUT_LENGTH,
496
501
  };
@@ -93,11 +93,16 @@ class DashboardServer {
93
93
 
94
94
  broadcast(type, payload) {
95
95
  const msg = JSON.stringify({ type, payload });
96
+ const toRemove = [];
96
97
  for (const ws of this.clients) {
97
98
  if (ws.readyState === 1) {
98
- try { ws.send(msg); } catch { this.clients.delete(ws); ws.terminate(); }
99
+ try { ws.send(msg); } catch { toRemove.push(ws); }
99
100
  }
100
101
  }
102
+ for (const ws of toRemove) {
103
+ this.clients.delete(ws);
104
+ try { ws.terminate(); } catch {}
105
+ }
101
106
  }
102
107
 
103
108
  sendJSON(res, data, status = 200) {
@@ -121,7 +126,7 @@ class DashboardServer {
121
126
  }
122
127
 
123
128
  async handleHTTP(req, res) {
124
- const url = new URL(req.url, `http://${req.headers.host}`);
129
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
125
130
  const p = url.pathname;
126
131
 
127
132
  if (p === '/' || p === '/index.html') {
@@ -183,21 +188,25 @@ class DashboardServer {
183
188
  const filePath = params.get('path');
184
189
  if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
185
190
  const resolved = path.resolve(filePath);
186
- const allowedPrefix = path.resolve(os.homedir(), '.claude', 'projects');
187
- // Resolve symlinks before prefix check to prevent symlink-based path traversal
191
+ // Resolve both the user-provided path AND the allowed prefix through realpath
192
+ // to ensure consistent comparison even if homedir contains symlinks
193
+ let realPath;
194
+ let allowedPrefix;
188
195
  try {
189
- const realPath = await fs.promises.realpath(resolved);
196
+ const homeReal = await fs.promises.realpath(os.homedir());
197
+ allowedPrefix = path.join(homeReal, '.claude', 'projects');
198
+ realPath = await fs.promises.realpath(resolved);
190
199
  if (!realPath.startsWith(allowedPrefix)) {
191
200
  this.sendJSON(res, { error: 'Access denied' }, 403);
192
201
  return;
193
202
  }
194
203
  } catch {
195
- // realpath fails for non-existent files — block them
204
+ // realpath fails for non-existent files or if homedir can't be resolved — block them
196
205
  this.sendJSON(res, { error: 'Access denied' }, 403);
197
206
  return;
198
207
  }
199
208
  try {
200
- const content = await fs.promises.readFile(resolved, 'utf-8');
209
+ const content = await fs.promises.readFile(realPath, 'utf-8');
201
210
  this.sendJSON(res, { content });
202
211
  } catch (err) {
203
212
  this.sendJSON(res, { error: err.message }, 404);
@@ -239,11 +248,13 @@ class DashboardServer {
239
248
  this.broadcast('autoDiscoveryChanged', { enabled: this.watcher.isAutoDiscoveryEnabled() });
240
249
  break;
241
250
  case 'removeSession':
242
- this.watcher.removeSession(cmd.sessionID);
243
- this.broadcast('sessionRemoved', { sessionID: cmd.sessionID });
251
+ if (typeof cmd.sessionID === 'string' && cmd.sessionID) {
252
+ this.watcher.removeSession(cmd.sessionID);
253
+ this.broadcast('sessionRemoved', { sessionID: cmd.sessionID });
254
+ }
244
255
  break;
245
256
  case 'setSkipHistory':
246
- this.watcher.setSkipHistory(cmd.skip);
257
+ this.watcher.setSkipHistory(cmd.skip === true);
247
258
  break;
248
259
  case 'getContext':
249
260
  this.sendContext(ws);
@@ -348,12 +359,14 @@ class DashboardServer {
348
359
  const confirmed = await askYesNo(`Port ${port} is occupied by process(es) ${pids.join(', ')}. Kill them? [y/N] `);
349
360
  if (!confirmed) {
350
361
  console.error(`Port ${port} is in use. Exiting.`);
362
+ this.stop();
351
363
  process.exit(1);
352
364
  }
353
365
 
366
+ const myPid = process.pid;
354
367
  for (const pid of pids) {
355
368
  const parsedPid = parseInt(pid, 10);
356
- if (Number.isInteger(parsedPid) && parsedPid > 0) {
369
+ if (Number.isInteger(parsedPid) && parsedPid > 1 && parsedPid !== myPid) {
357
370
  try {
358
371
  if (process.platform === 'win32') {
359
372
  cp.execSync(`taskkill /PID ${parsedPid} /F`, { encoding: 'utf-8' });
@@ -369,7 +382,7 @@ class DashboardServer {
369
382
  await new Promise(r => setTimeout(r, 3000));
370
383
  for (const pid of pids) {
371
384
  const parsedPid = parseInt(pid, 10);
372
- if (Number.isInteger(parsedPid) && parsedPid > 0) {
385
+ if (Number.isInteger(parsedPid) && parsedPid > 1 && parsedPid !== myPid) {
373
386
  try { process.kill(parsedPid, 0); process.kill(parsedPid, 'SIGKILL'); } catch {}
374
387
  }
375
388
  }
@@ -423,9 +436,11 @@ class DashboardServer {
423
436
  this.server.on('error', (err) => {
424
437
  if (err.code === 'EADDRINUSE') {
425
438
  console.error(`Port ${this.port} is still in use after attempting to free it. Exiting.`);
439
+ this.stop();
426
440
  process.exit(1);
427
441
  } else {
428
442
  console.error(`Server error: ${err.message}`);
443
+ this.stop();
429
444
  process.exit(1);
430
445
  }
431
446
  });
@@ -438,6 +453,7 @@ class DashboardServer {
438
453
  await w.start();
439
454
  } catch (err) {
440
455
  console.error('Watcher init error:', err.message);
456
+ this.stop();
441
457
  process.exit(1);
442
458
  }
443
459
 
@@ -490,6 +506,7 @@ async function startServer(options = {}) {
490
506
  }
491
507
 
492
508
  function askYesNo(prompt) {
509
+ if (!process.stdin.isTTY) return Promise.resolve(false);
493
510
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
494
511
  return new Promise(resolve => {
495
512
  rl.question(prompt, answer => {
@@ -772,32 +772,42 @@ class Watcher extends EventEmitter {
772
772
  if (!line.includes('"tool_')) continue;
773
773
 
774
774
  if (line.includes('"tool_use"')) {
775
- const idMatch = line.match(/"id"\s*:\s*"([^"]+)"/);
776
- if (!idMatch) continue;
777
- const tid = idMatch[1];
778
- if (session.toolIndex.has(tid)) continue;
779
- const nameMatch = line.match(/"name"\s*:\s*"([^"]+)"/);
780
- session.toolIndex.set(tid, {
781
- toolName: nameMatch ? nameMatch[1] : '',
782
- parentAgentID: agentID,
783
- hasResult: false,
784
- });
775
+ try {
776
+ var raw = JSON.parse(line);
777
+ var content = raw.message && raw.message.content;
778
+ if (!Array.isArray(content)) continue;
779
+ for (var block of content) {
780
+ if (block.type !== 'tool_use' || !block.id) continue;
781
+ if (session.toolIndex.has(block.id)) continue;
782
+ session.toolIndex.set(block.id, {
783
+ toolName: block.name || '',
784
+ parentAgentID: agentID,
785
+ hasResult: false,
786
+ });
787
+ }
788
+ } catch { continue; }
785
789
  }
786
790
 
787
791
  if (line.includes('"tool_result"')) {
788
- const useIdMatch = line.match(/"tool_use_id"\s*:\s*"([^"]+)"/);
789
- if (!useIdMatch) continue;
790
- const tid = useIdMatch[1];
791
- const existing = session.toolIndex.get(tid);
792
- if (existing) {
793
- existing.hasResult = true;
794
- } else {
795
- session.toolIndex.set(tid, {
796
- toolName: '',
797
- parentAgentID: '',
798
- hasResult: true,
799
- });
800
- }
792
+ try {
793
+ var raw2 = JSON.parse(line);
794
+ var content2 = raw2.message && raw2.message.content;
795
+ if (!Array.isArray(content2)) continue;
796
+ for (var block2 of content2) {
797
+ if (block2.type !== 'tool_result' || !block2.tool_use_id) continue;
798
+ var tid = block2.tool_use_id;
799
+ var existing = session.toolIndex.get(tid);
800
+ if (existing) {
801
+ existing.hasResult = true;
802
+ } else {
803
+ session.toolIndex.set(tid, {
804
+ toolName: '',
805
+ parentAgentID: '',
806
+ hasResult: true,
807
+ });
808
+ }
809
+ }
810
+ } catch { continue; }
801
811
  }
802
812
  }
803
813
  } catch (err) {
@@ -971,7 +981,7 @@ class Watcher extends EventEmitter {
971
981
  const { bytesRead } = await handle.read(buf, 0, readLen, readFrom);
972
982
  if (bytesRead === 0) break;
973
983
 
974
- const chunk = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
984
+ const chunk = buf.toString('utf-8', 0, bytesRead);
975
985
  const combined = carryOver + chunk;
976
986
 
977
987
  // Detect CRLF from first newline in the combined text
@@ -1217,7 +1227,7 @@ async function _listSessionsFiltered(limit, activeWithin) {
1217
1227
 
1218
1228
  const candidates = [];
1219
1229
  try {
1220
- await _walkDirStatic(claudeDir, (filePath, stats) => {
1230
+ await _walkDirAsync(claudeDir, (filePath, stats) => {
1221
1231
  if (!isMainSessionFile(filePath, stats)) return;
1222
1232
  if (activeWithin > 0 && (now - stats.mtimeMs) > activeWithin) return;
1223
1233
  candidates.push({ filePath, stats });
@@ -1244,14 +1254,13 @@ async function _listSessionsFiltered(limit, activeWithin) {
1244
1254
  return sessions;
1245
1255
  }
1246
1256
 
1247
- async function _walkDirStatic(dir, callback) {
1248
- return _walkDirAsync(dir, callback);
1249
- }
1250
-
1251
1257
  module.exports = {
1252
1258
  Watcher,
1253
1259
  Session,
1254
1260
  BackgroundTask,
1255
1261
  listSessions,
1256
1262
  listActiveSessions,
1263
+ resolveProjectPath,
1264
+ isMainSessionFile,
1265
+ readAgentType,
1257
1266
  };