claude-code-watch 0.0.10 → 0.0.12

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() {
@@ -118,9 +113,8 @@ async function runUpdate() {
118
113
  console.log(` Latest version: v${latest}`);
119
114
  console.log(' Running npm install -g claude-code-watch@latest...\n');
120
115
 
121
- const { execSync } = require('child_process');
122
116
  try {
123
- execSync('npm install -g claude-code-watch@latest', { stdio: 'inherit' });
117
+ cp.execSync('npm install -g claude-code-watch@latest', { stdio: 'inherit' });
124
118
  console.log(`\n Updated to v${latest}. Restart to use the new version.`);
125
119
  } catch {
126
120
  console.error('\n Update failed. Try manually: npm install -g claude-code-watch@latest');
@@ -128,19 +122,6 @@ async function runUpdate() {
128
122
  }
129
123
  }
130
124
 
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
125
  async function main() {
145
126
  const args = process.argv.slice(2);
146
127
 
@@ -157,9 +138,16 @@ async function main() {
157
138
  openBrowser: true,
158
139
  };
159
140
 
160
- // First pass: collect all option values
141
+ // Action flags
142
+ let listSessionsLimit = 0; // 0 = no list, >0 = limit
143
+ let listActiveLimit = 0; // 0 = no list, >0 = limit, -1 = all
144
+ let showVersion = false;
145
+ let showHelp = false;
146
+ let doUpdate = false;
147
+
161
148
  for (let i = 0; i < args.length; i++) {
162
- switch (args[i]) {
149
+ const arg = args[i];
150
+ switch (arg) {
163
151
  case '-s':
164
152
  options.sessionID = args[++i] || '';
165
153
  break;
@@ -169,7 +157,7 @@ async function main() {
169
157
  case '-p':
170
158
  case '--port': {
171
159
  if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
172
- console.error(`Error: ${args[i]} requires a port number`);
160
+ console.error(`Error: ${arg} requires a port number`);
173
161
  process.exit(1);
174
162
  }
175
163
  const pv = parseInt(args[++i], 10);
@@ -183,7 +171,7 @@ async function main() {
183
171
  case '-h':
184
172
  case '--host':
185
173
  if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
186
- console.error(`Error: ${args[i]} requires a host address`);
174
+ console.error(`Error: ${arg} requires a host address`);
187
175
  process.exit(1);
188
176
  }
189
177
  options.host = args[++i];
@@ -214,75 +202,85 @@ async function main() {
214
202
  case '--no-open':
215
203
  options.openBrowser = false;
216
204
  break;
217
- default:
218
- break;
219
- }
220
- }
221
-
222
- // Second pass: execute action flags with fully resolved options
223
- for (let i = 0; i < args.length; i++) {
224
- switch (args[i]) {
225
205
  case '-l': {
226
- const v = parseInt(args[i + 1]);
227
- const limit = !isNaN(v) ? v : 10;
206
+ const next = args[i + 1];
207
+ const v = parseInt(next);
208
+ listSessionsLimit = !isNaN(v) ? v : 10;
228
209
  if (!isNaN(v)) i++;
229
- const sessions = await listSessions(limit);
230
- if (sessions.length === 0) {
231
- console.log('No sessions found.');
232
- } else {
233
- const now = Date.now();
234
- for (const s of sessions) {
235
- const age = Math.round((now - new Date(s.modified).getTime()) / 1000);
236
- const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
237
- const active = s.isActive ? '●' : '○';
238
- const id = s.id.length > 40 ? s.id.slice(0, 37) + '...' : s.id;
239
- console.log(`${active} ${id} ${s.projectPath || '?'} ${ageStr}`);
240
- }
241
- }
242
- return;
210
+ break;
243
211
  }
244
212
  case '-a': {
245
- const v = parseInt(args[i + 1]);
246
- const limit = !isNaN(v) ? v : 0;
247
- if (!isNaN(v)) i++;
248
- const sessions = await listActiveSessions(options.activeWindow);
249
- const result = limit > 0 ? sessions.slice(0, limit) : sessions;
250
- if (result.length === 0) {
251
- console.log('No active sessions found.');
252
- } else {
253
- const now = Date.now();
254
- for (const s of result) {
255
- const age = Math.round((now - new Date(s.modified).getTime()) / 1000);
256
- const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
257
- const id = s.id.length > 40 ? s.id.slice(0, 37) + '...' : s.id;
258
- console.log(`● ${id} ${s.projectPath || '?'} ${ageStr}`);
259
- }
260
- }
261
- return;
213
+ const next = args[i + 1];
214
+ const v = parseInt(next);
215
+ if (!isNaN(v)) { listActiveLimit = v; i++; }
216
+ else { listActiveLimit = -1; }
217
+ break;
262
218
  }
263
219
  case '-v':
264
- console.log(`claude-watch v${VERSION}`);
265
- return;
220
+ showVersion = true;
221
+ break;
266
222
  case '--help':
267
- printHelp();
268
- return;
223
+ showHelp = true;
224
+ break;
269
225
  case 'update':
270
- await runUpdate();
271
- return;
272
- // Skip option flags already handled in first pass
273
- case '-s': case '-n': case '-p': case '--port':
274
- case '-h': case '--host': case '-w': case '-c':
275
- case '-m': case '-D': case '--poll': case '--no-open':
226
+ doUpdate = true;
276
227
  break;
277
228
  default:
278
- if (args[i].startsWith('-')) {
279
- console.error(`Unknown option: ${args[i]}`);
229
+ if (arg.startsWith('-')) {
230
+ console.error(`Unknown option: ${arg}`);
280
231
  printHelp();
281
232
  process.exit(1);
282
233
  }
283
234
  }
284
235
  }
285
236
 
237
+ // Execute action flags
238
+ if (showVersion) {
239
+ printVersion();
240
+ return;
241
+ }
242
+ if (showHelp) {
243
+ printHelp();
244
+ return;
245
+ }
246
+ if (doUpdate) {
247
+ await runUpdate();
248
+ return;
249
+ }
250
+ if (listSessionsLimit > 0) {
251
+ const sessions = await listSessions(listSessionsLimit);
252
+ if (sessions.length === 0) {
253
+ console.log('No sessions found.');
254
+ } else {
255
+ const now = Date.now();
256
+ for (const s of sessions) {
257
+ const age = Math.round((now - new Date(s.modified).getTime()) / 1000);
258
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
259
+ const active = s.isActive ? '●' : '○';
260
+ const id = s.id.length > 40 ? s.id.slice(0, 37) + '...' : s.id;
261
+ console.log(`${active} ${id} ${s.projectPath || '?'} ${ageStr}`);
262
+ }
263
+ }
264
+ return;
265
+ }
266
+ if (listActiveLimit !== 0) {
267
+ const limit = listActiveLimit > 0 ? listActiveLimit : 0;
268
+ const sessions = await listActiveSessions(options.activeWindow);
269
+ const result = limit > 0 ? sessions.slice(0, limit) : sessions;
270
+ if (result.length === 0) {
271
+ console.log('No active sessions found.');
272
+ } else {
273
+ const now = Date.now();
274
+ for (const s of result) {
275
+ const age = Math.round((now - new Date(s.modified).getTime()) / 1000);
276
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
277
+ const id = s.id.length > 40 ? s.id.slice(0, 37) + '...' : s.id;
278
+ console.log(`● ${id} ${s.projectPath || '?'} ${ageStr}`);
279
+ }
280
+ }
281
+ return;
282
+ }
283
+
286
284
  checkForUpdate();
287
285
  startServer(options);
288
286
  }
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.12",
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,23 +387,25 @@ 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)
393
393
  // ══════════════════════════════════════════════════════════════════════════════
394
394
 
395
395
  const mdRenderer = new marked.Renderer();
396
- mdRenderer.code = function (code, lang) {
396
+ mdRenderer.code = function (codeOrObj, langOrEsc) {
397
+ // marked v4: code(text, lang, escaped) — marked v5+: code({ text, lang })
398
+ const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
399
+ const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
397
400
  let highlighted;
398
401
  if (lang && hljs.getLanguage(lang)) {
399
402
  try {
400
- highlighted = hljs.highlight(code, { language: lang }).value;
403
+ highlighted = hljs.highlight(text, { language: lang }).value;
401
404
  } catch {
402
- highlighted = hljs.highlightAuto(code).value;
405
+ highlighted = hljs.highlightAuto(text).value;
403
406
  }
404
407
  } else {
405
- highlighted = hljs.highlightAuto(code).value;
408
+ highlighted = hljs.highlightAuto(text).value;
406
409
  }
407
410
  const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
408
411
  return `<div class="code-block-wrapper">
@@ -588,8 +591,8 @@ function handleNewBgTask(payload) {
588
591
  function handleSessionRemoved(payload) {
589
592
  const idx = sessions.findIndex(s => s.id === payload.sessionID);
590
593
  if (idx >= 0) {
591
- const session = sessions.splice(idx, 1)[0];
592
- sessions.push(session);
594
+ sessions.splice(idx, 1);
595
+ sessionsMap.delete(payload.sessionID);
593
596
  }
594
597
  updateFilters();
595
598
  rebuildNodes();
@@ -733,7 +736,7 @@ function getNodeHTML(node, idx) {
733
736
  return `<div class="tree-row${selClass ? ' selected' : ''}">
734
737
  <div class="tree-node" onclick="treeClick(${idx})" data-idx="${idx}">
735
738
  <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} ${esc(displayName)}
736
- ${node.collapsed && agentCount > 0 ? `(${agentCount})` : ''}
739
+ ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
737
740
  ${subInfo}
738
741
  </div>
739
742
  <span class="tree-actions">
@@ -1112,11 +1115,11 @@ function removeSelectedSession() {
1112
1115
  if (node.type === 'session') sid = node.id;
1113
1116
  else sid = node.sessionID;
1114
1117
  if (!sid) return;
1115
- if (!confirm(`Move session ${sid.slice(0, 12)}... to bottom?`)) return;
1118
+ if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1116
1119
  const idx = sessions.findIndex(s => s.id === sid);
1117
1120
  if (idx >= 0) {
1118
- const session = sessions.splice(idx, 1)[0];
1119
- sessions.push(session);
1121
+ sessions.splice(idx, 1);
1122
+ sessionsMap.delete(sid);
1120
1123
  }
1121
1124
  sendCmd('removeSession', { sessionID: sid });
1122
1125
  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
  };
@@ -55,6 +55,8 @@ class DashboardServer {
55
55
  ctx = { inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, model: '', contextWindow: 200000, lastActivity: Date.now() };
56
56
  this.contextMap.set(key, ctx);
57
57
  }
58
+ // inputTokens: Claude API returns cumulative total per call, not incremental — use Math.max
59
+ // outputTokens/cache tokens: API returns incremental values — use +=
58
60
  if (item.inputTokens) ctx.inputTokens = Math.max(ctx.inputTokens, item.inputTokens);
59
61
  if (item.outputTokens) ctx.outputTokens += item.outputTokens;
60
62
  if (item.cacheCreationTokens) ctx.cacheCreation += item.cacheCreationTokens;
@@ -93,9 +95,16 @@ class DashboardServer {
93
95
 
94
96
  broadcast(type, payload) {
95
97
  const msg = JSON.stringify({ type, payload });
98
+ const toRemove = [];
96
99
  for (const ws of this.clients) {
97
100
  if (ws.readyState === 1) {
98
- try { ws.send(msg); } catch { this.clients.delete(ws); ws.terminate(); }
101
+ try { ws.send(msg); } catch { toRemove.push(ws); }
102
+ }
103
+ }
104
+ for (const ws of toRemove) {
105
+ this.clients.delete(ws);
106
+ try { ws.terminate(); } catch (err) {
107
+ if (this.debugAll) console.error('[server] terminate error:', err.message);
99
108
  }
100
109
  }
101
110
  }
@@ -121,7 +130,7 @@ class DashboardServer {
121
130
  }
122
131
 
123
132
  async handleHTTP(req, res) {
124
- const url = new URL(req.url, `http://${req.headers.host}`);
133
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
125
134
  const p = url.pathname;
126
135
 
127
136
  if (p === '/' || p === '/index.html') {
@@ -183,21 +192,25 @@ class DashboardServer {
183
192
  const filePath = params.get('path');
184
193
  if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
185
194
  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
195
+ // Resolve both the user-provided path AND the allowed prefix through realpath
196
+ // to ensure consistent comparison even if homedir contains symlinks
197
+ let realPath;
198
+ let allowedPrefix;
188
199
  try {
189
- const realPath = await fs.promises.realpath(resolved);
200
+ const homeReal = await fs.promises.realpath(os.homedir());
201
+ allowedPrefix = path.join(homeReal, '.claude', 'projects');
202
+ realPath = await fs.promises.realpath(resolved);
190
203
  if (!realPath.startsWith(allowedPrefix)) {
191
204
  this.sendJSON(res, { error: 'Access denied' }, 403);
192
205
  return;
193
206
  }
194
207
  } catch {
195
- // realpath fails for non-existent files — block them
208
+ // realpath fails for non-existent files or if homedir can't be resolved — block them
196
209
  this.sendJSON(res, { error: 'Access denied' }, 403);
197
210
  return;
198
211
  }
199
212
  try {
200
- const content = await fs.promises.readFile(resolved, 'utf-8');
213
+ const content = await fs.promises.readFile(realPath, 'utf-8');
201
214
  this.sendJSON(res, { content });
202
215
  } catch (err) {
203
216
  this.sendJSON(res, { error: err.message }, 404);
@@ -215,7 +228,9 @@ class DashboardServer {
215
228
  try {
216
229
  const cmd = JSON.parse(data.toString('utf-8'));
217
230
  this.handleCommand(ws, cmd);
218
- } catch {}
231
+ } catch (err) {
232
+ if (this.debugAll) console.error('[server] WS message error:', err.message);
233
+ }
219
234
  });
220
235
 
221
236
  ws.on('close', () => {
@@ -239,11 +254,13 @@ class DashboardServer {
239
254
  this.broadcast('autoDiscoveryChanged', { enabled: this.watcher.isAutoDiscoveryEnabled() });
240
255
  break;
241
256
  case 'removeSession':
242
- this.watcher.removeSession(cmd.sessionID);
243
- this.broadcast('sessionRemoved', { sessionID: cmd.sessionID });
257
+ if (typeof cmd.sessionID === 'string' && cmd.sessionID) {
258
+ this.watcher.removeSession(cmd.sessionID);
259
+ this.broadcast('sessionRemoved', { sessionID: cmd.sessionID });
260
+ }
244
261
  break;
245
262
  case 'setSkipHistory':
246
- this.watcher.setSkipHistory(cmd.skip);
263
+ this.watcher.setSkipHistory(cmd.skip === true);
247
264
  break;
248
265
  case 'getContext':
249
266
  this.sendContext(ws);
@@ -348,19 +365,23 @@ class DashboardServer {
348
365
  const confirmed = await askYesNo(`Port ${port} is occupied by process(es) ${pids.join(', ')}. Kill them? [y/N] `);
349
366
  if (!confirmed) {
350
367
  console.error(`Port ${port} is in use. Exiting.`);
368
+ this.stop();
351
369
  process.exit(1);
352
370
  }
353
371
 
372
+ const myPid = process.pid;
354
373
  for (const pid of pids) {
355
374
  const parsedPid = parseInt(pid, 10);
356
- if (Number.isInteger(parsedPid) && parsedPid > 0) {
375
+ if (Number.isInteger(parsedPid) && parsedPid > 1 && parsedPid !== myPid) {
357
376
  try {
358
377
  if (process.platform === 'win32') {
359
378
  cp.execSync(`taskkill /PID ${parsedPid} /F`, { encoding: 'utf-8' });
360
379
  } else {
361
380
  process.kill(parsedPid, 'SIGTERM');
362
381
  }
363
- } catch {}
382
+ } catch (err) {
383
+ console.error(`[server] Failed to SIGTERM pid ${parsedPid}: ${err.message}`);
384
+ }
364
385
  }
365
386
  }
366
387
 
@@ -369,8 +390,10 @@ class DashboardServer {
369
390
  await new Promise(r => setTimeout(r, 3000));
370
391
  for (const pid of pids) {
371
392
  const parsedPid = parseInt(pid, 10);
372
- if (Number.isInteger(parsedPid) && parsedPid > 0) {
373
- try { process.kill(parsedPid, 0); process.kill(parsedPid, 'SIGKILL'); } catch {}
393
+ if (Number.isInteger(parsedPid) && parsedPid > 1 && parsedPid !== myPid) {
394
+ try { process.kill(parsedPid, 0); process.kill(parsedPid, 'SIGKILL'); } catch {
395
+ // Process already gone — nothing to do
396
+ }
374
397
  }
375
398
  }
376
399
  }
@@ -423,9 +446,11 @@ class DashboardServer {
423
446
  this.server.on('error', (err) => {
424
447
  if (err.code === 'EADDRINUSE') {
425
448
  console.error(`Port ${this.port} is still in use after attempting to free it. Exiting.`);
449
+ this.stop();
426
450
  process.exit(1);
427
451
  } else {
428
452
  console.error(`Server error: ${err.message}`);
453
+ this.stop();
429
454
  process.exit(1);
430
455
  }
431
456
  });
@@ -438,6 +463,7 @@ class DashboardServer {
438
463
  await w.start();
439
464
  } catch (err) {
440
465
  console.error('Watcher init error:', err.message);
466
+ this.stop();
441
467
  process.exit(1);
442
468
  }
443
469
 
@@ -490,6 +516,7 @@ async function startServer(options = {}) {
490
516
  }
491
517
 
492
518
  function askYesNo(prompt) {
519
+ if (!process.stdin.isTTY) return Promise.resolve(false);
493
520
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
494
521
  return new Promise(resolve => {
495
522
  rl.question(prompt, answer => {
@@ -424,15 +424,21 @@ class Watcher extends EventEmitter {
424
424
 
425
425
  if (p.endsWith('.jsonl')) {
426
426
  if (p.includes('/subagents/')) {
427
- this._handleNewSubagentFile(p);
427
+ this._handleNewSubagentFile(p).catch(err => {
428
+ if (this.debug) console.error('[watcher] _handleNewSubagentFile error:', err.message);
429
+ });
428
430
  } else if (this.watchActive) {
429
- this._handleNewSessionFile(p); // fire-and-forget, session will be discovered on next poll
431
+ this._handleNewSessionFile(p).catch(err => {
432
+ if (this.debug) console.error('[watcher] _handleNewSessionFile error:', err.message);
433
+ });
430
434
  }
431
435
  return;
432
436
  }
433
437
 
434
438
  if (p.endsWith('.txt') && p.includes('/tool-results/')) {
435
- this._handleNewToolResultFile(p);
439
+ this._handleNewToolResultFile(p).catch(err => {
440
+ if (this.debug) console.error('[watcher] _handleNewToolResultFile error:', err.message);
441
+ });
436
442
  }
437
443
  }
438
444
 
@@ -448,9 +454,13 @@ class Watcher extends EventEmitter {
448
454
  continue;
449
455
  }
450
456
  if (base === 'subagents' && entry.name.endsWith('.jsonl')) {
451
- this._handleNewSubagentFile(fullPath);
457
+ this._handleNewSubagentFile(fullPath).catch(err => {
458
+ if (this.debug) console.error('[watcher] _handleNewSubagentFile error:', err.message);
459
+ });
452
460
  } else if (base === 'tool-results' && entry.name.endsWith('.txt')) {
453
- this._handleNewToolResultFile(fullPath);
461
+ this._handleNewToolResultFile(fullPath).catch(err => {
462
+ if (this.debug) console.error('[watcher] _handleNewToolResultFile error:', err.message);
463
+ });
454
464
  }
455
465
  }
456
466
  }
@@ -542,7 +552,9 @@ class Watcher extends EventEmitter {
542
552
  this.pendingSubagents.delete(session.id);
543
553
  for (const sp of pending) {
544
554
  const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
545
- this._registerSubagent(session, session.id, agentID, sp);
555
+ this._registerSubagent(session, session.id, agentID, sp).catch(err => {
556
+ if (this.debug) console.error('[watcher] _registerSubagent error (pending):', err.message);
557
+ });
546
558
  }
547
559
  }
548
560
  }
@@ -561,7 +573,9 @@ class Watcher extends EventEmitter {
561
573
  return;
562
574
  }
563
575
 
564
- this._registerSubagent(session, sessionID, agentID, p); // fire-and-forget, event handler context
576
+ this._registerSubagent(session, sessionID, agentID, p).catch(err => {
577
+ if (this.debug) console.error('[watcher] _registerSubagent error:', err.message);
578
+ });
565
579
  }
566
580
 
567
581
  async _registerSubagent(session, sessionID, agentID, p) {
@@ -772,32 +786,42 @@ class Watcher extends EventEmitter {
772
786
  if (!line.includes('"tool_')) continue;
773
787
 
774
788
  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
- });
789
+ try {
790
+ var raw = JSON.parse(line);
791
+ var content = raw.message && raw.message.content;
792
+ if (!Array.isArray(content)) continue;
793
+ for (var block of content) {
794
+ if (block.type !== 'tool_use' || !block.id) continue;
795
+ if (session.toolIndex.has(block.id)) continue;
796
+ session.toolIndex.set(block.id, {
797
+ toolName: block.name || '',
798
+ parentAgentID: agentID,
799
+ hasResult: false,
800
+ });
801
+ }
802
+ } catch { continue; }
785
803
  }
786
804
 
787
805
  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
- }
806
+ try {
807
+ var raw2 = JSON.parse(line);
808
+ var content2 = raw2.message && raw2.message.content;
809
+ if (!Array.isArray(content2)) continue;
810
+ for (var block2 of content2) {
811
+ if (block2.type !== 'tool_result' || !block2.tool_use_id) continue;
812
+ var tid = block2.tool_use_id;
813
+ var existing = session.toolIndex.get(tid);
814
+ if (existing) {
815
+ existing.hasResult = true;
816
+ } else {
817
+ session.toolIndex.set(tid, {
818
+ toolName: '',
819
+ parentAgentID: '',
820
+ hasResult: true,
821
+ });
822
+ }
823
+ }
824
+ } catch { continue; }
801
825
  }
802
826
  }
803
827
  } catch (err) {
@@ -971,7 +995,7 @@ class Watcher extends EventEmitter {
971
995
  const { bytesRead } = await handle.read(buf, 0, readLen, readFrom);
972
996
  if (bytesRead === 0) break;
973
997
 
974
- const chunk = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
998
+ const chunk = buf.toString('utf-8', 0, bytesRead);
975
999
  const combined = carryOver + chunk;
976
1000
 
977
1001
  // Detect CRLF from first newline in the combined text
@@ -1217,7 +1241,7 @@ async function _listSessionsFiltered(limit, activeWithin) {
1217
1241
 
1218
1242
  const candidates = [];
1219
1243
  try {
1220
- await _walkDirStatic(claudeDir, (filePath, stats) => {
1244
+ await _walkDirAsync(claudeDir, (filePath, stats) => {
1221
1245
  if (!isMainSessionFile(filePath, stats)) return;
1222
1246
  if (activeWithin > 0 && (now - stats.mtimeMs) > activeWithin) return;
1223
1247
  candidates.push({ filePath, stats });
@@ -1244,14 +1268,13 @@ async function _listSessionsFiltered(limit, activeWithin) {
1244
1268
  return sessions;
1245
1269
  }
1246
1270
 
1247
- async function _walkDirStatic(dir, callback) {
1248
- return _walkDirAsync(dir, callback);
1249
- }
1250
-
1251
1271
  module.exports = {
1252
1272
  Watcher,
1253
1273
  Session,
1254
1274
  BackgroundTask,
1255
1275
  listSessions,
1256
1276
  listActiveSessions,
1277
+ resolveProjectPath,
1278
+ isMainSessionFile,
1279
+ readAgentType,
1257
1280
  };