@axiomatic-labs/claudeflow 2.13.57 → 2.13.59

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/lib/doctor.js CHANGED
@@ -129,21 +129,66 @@ function checkStaleLockfiles(cwd) {
129
129
  };
130
130
  }
131
131
 
132
- // Read the browser-error-daemon observer state from its pidfile.
133
- // Returns { running, pid, lockfile } running=true only when the pid is
134
- // alive AND the lockfile exists. Used by both doctor and panel.
135
- function readObserverState(cwd, cdpPort) {
136
- const lockfile = path.join(cwd, '.claudeflow', 'tmp', `.browser-cdp-${cdpPort}.pid`);
137
- let pid = null;
132
+ // Read the browser-error-daemon observer state. The daemon is the Node
133
+ // process that hosts the HTTP server at error-observer.json's `port` and
134
+ // receives error/network reports from Chrome.
135
+ //
136
+ // CAVEAT this is NOT the same as Chrome's CDP pidfile:
137
+ // - `.browser-cdp-{cdpPort}.pid` → Chrome browser process (CDP port)
138
+ // - `error-observer.pid` / json → Node daemon process (observer port)
139
+ // These are two distinct processes. Previous versions of this function
140
+ // looked at the Chrome pidfile and reported the observer "stopped" even
141
+ // when the daemon was alive, because the cdp-port pidfile may not be
142
+ // present when Chrome was started outside of claudeflow's openBrowser().
143
+ //
144
+ // Detection strategy (in order of preference):
145
+ // 1. error-observer.json exists → read its `port` + `pid_path`.
146
+ // 2. If pidfile exists and PID is alive → running=true.
147
+ // 3. Else, TCP probe the observer port via `lsof -i :PORT -sTCP:LISTEN -t`.
148
+ // The pidfile may be missing (race) but if SOMETHING is listening on
149
+ // the configured port, the daemon is up.
150
+ function readObserverState(cwd) {
151
+ const observerJsonPath = path.join(cwd, '.claudeflow', 'tmp', 'error-observer.json');
152
+ let meta;
138
153
  try {
139
- pid = parseInt(fs.readFileSync(lockfile, 'utf8').trim(), 10);
154
+ meta = JSON.parse(fs.readFileSync(observerJsonPath, 'utf8'));
140
155
  } catch {
141
- return { running: false, pid: null, lockfile };
156
+ return { running: false, pid: null, port: null, source: 'no-observer-json', observerJsonPath };
157
+ }
158
+ if (!meta || typeof meta.port !== 'number') {
159
+ return { running: false, pid: null, port: null, source: 'observer-json-malformed', observerJsonPath };
160
+ }
161
+ const port = meta.port;
162
+ const pidPath = typeof meta.pid_path === 'string' ? meta.pid_path : null;
163
+
164
+ // Try the pidfile first — fastest path.
165
+ let pid = null;
166
+ if (pidPath) {
167
+ try {
168
+ const p = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
169
+ if (Number.isFinite(p) && p > 0) pid = p;
170
+ } catch {}
171
+ }
172
+ if (pid !== null) {
173
+ let alive = false;
174
+ try { process.kill(pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
175
+ if (alive) return { running: true, pid, port, source: 'pidfile', observerJsonPath };
142
176
  }
143
- if (!Number.isFinite(pid) || pid <= 0) return { running: false, pid, lockfile };
144
- let alive = false;
145
- try { process.kill(pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
146
- return { running: alive, pid, lockfile };
177
+
178
+ // Fallback: TCP probe via lsof. Catches the case where pidfile is missing
179
+ // but the daemon process is still listening on the port.
180
+ try {
181
+ const { spawnSync } = require('child_process');
182
+ const r = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', timeout: 1500 });
183
+ if (r.status === 0 && r.stdout && r.stdout.trim()) {
184
+ const tcpPid = parseInt(r.stdout.trim().split('\n')[0], 10);
185
+ if (Number.isFinite(tcpPid) && tcpPid > 0) {
186
+ return { running: true, pid: tcpPid, port, source: 'tcp-probe', observerJsonPath };
187
+ }
188
+ }
189
+ } catch {}
190
+
191
+ return { running: false, pid: pid, port, source: 'no-listener', observerJsonPath };
147
192
  }
148
193
 
149
194
  // Surface as a doctor/panel issue when the browser-error-daemon observer
@@ -161,19 +206,25 @@ function checkObserverState(cwd) {
161
206
  if (!hasPlaywright) {
162
207
  return { id: 'observer-state', severity: 'info', message: 'Playwright not configured — observer not applicable.' };
163
208
  }
164
- const observer = readObserverState(cwd, deriveCdpPort(cwd));
209
+ const observer = readObserverState(cwd);
165
210
  if (observer.running) {
166
211
  return {
167
212
  id: 'observer-state',
168
213
  severity: 'ok',
169
- message: `browser-error-daemon running (pid ${observer.pid}).`,
214
+ message: `browser-error-daemon running on port ${observer.port} (pid ${observer.pid}, detected via ${observer.source}).`,
170
215
  };
171
216
  }
217
+ // Distinguish "never started" (no observer JSON) from "started then died".
218
+ const detailMsg = observer.source === 'no-observer-json'
219
+ ? 'no `.claudeflow/tmp/error-observer.json` — the daemon never started this session. Check `.claude/hooks/SessionStart/browser-error-daemon.js` for failures.'
220
+ : observer.source === 'observer-json-malformed'
221
+ ? '`.claudeflow/tmp/error-observer.json` is malformed.'
222
+ : 'observer JSON exists (port ' + observer.port + ') but no process is listening — the daemon crashed after startup. Tail `.claude/tmp/hooks.log` for the most recent SessionStart entry.';
172
223
  return {
173
224
  id: 'observer-state',
174
225
  severity: 'mismatch',
175
- message: 'browser-error-daemon is STOPPED while Playwright is configured. Console errors / network failures from headed sessions are NOT being collected — the agent may verify a flow with console errors and miss them. The observer should auto-start at SessionStart via `.claude/hooks/SessionStart/browser-error-daemon.js`; if it keeps stopping, inspect that hook for crashes or check the pidfile at `.claudeflow/tmp/.browser-cdp-<port>.pid`.',
176
- detail: [{ lockfile: observer.lockfile, pid: observer.pid }],
226
+ message: `browser-error-daemon is STOPPED while Playwright is configured. Console errors / network failures from headed sessions are NOT being collected — the agent may verify a flow with console errors and miss them. Diagnosis: ${detailMsg}`,
227
+ detail: [{ port: observer.port, source: observer.source, observerJsonPath: observer.observerJsonPath }],
177
228
  };
178
229
  }
179
230
 
package/lib/panel.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  checkObserverState,
23
23
  readObserverState: readObserverStateFromDoctor,
24
24
  readPlaywrightCdpEndpoint,
25
+ applyCdpPortFix,
25
26
  } = require('./doctor.js');
26
27
  const {
27
28
  readOverrides,
@@ -195,7 +196,7 @@ function getMcpInfo(cwd) {
195
196
  const servers = Object.keys(cfg.mcpServers || {});
196
197
  const cdp = checkCdpPortMismatch(cwd);
197
198
  const reading = readPlaywrightCdpEndpoint(mcpPath);
198
- const observer = readObserverState(cwd, deriveCdpPort(cwd));
199
+ const observer = readObserverState(cwd);
199
200
  const lockfiles = checkStaleLockfiles(cwd);
200
201
 
201
202
  return {
@@ -205,6 +206,10 @@ function getMcpInfo(cwd) {
205
206
  configured: reading.state === 'ok' ? reading.port : null,
206
207
  computed: deriveCdpPort(cwd),
207
208
  match: cdp.severity === 'ok',
209
+ // `fixable` is true only for the "wrong port" mismatch case — the
210
+ // applyCdpPortFix routine can rewrite the args entry. Other cases
211
+ // (invalid JSON, unparseable value) can't be auto-fixed by the panel.
212
+ fixable: cdp.severity === 'mismatch',
208
213
  state: reading.state,
209
214
  message: cdp.message,
210
215
  },
@@ -1302,6 +1307,7 @@ function renderMcp() {
1302
1307
  \${row('Playwright --cdp-endpoint port', String(m.playwright.configured || 'n/a'))}
1303
1308
  \${row('Computed port (from path)', String(m.playwright.computed))}
1304
1309
  \${row('Match', m.playwright.match ? 'YES' : 'NO', { kind: m.playwright.match ? 'ok' : 'err', text: m.playwright.match ? '✓' : '✗' })}
1310
+ \${m.playwright.fixable ? '<div style="padding:10px 14px;border-top:1px solid var(--border);"><button id="fix-cdp-port" class="btn-warn">Fix CDP port</button> <span class="muted" style="margin-left:10px;font-size:12px;">Rewrites .mcp.json --cdp-endpoint to localhost:'+m.playwright.computed+' (the port this machine derives from the cwd).</span></div>' : ''}
1305
1311
  \${row('Observer', m.observer.running ? 'running (pid '+m.observer.pid+')' : 'STOPPED — browser errors NOT being collected', { kind: m.observer.running ? 'ok' : (m.playwright.match ? 'warn' : 'info'), text: m.observer.running ? '✓' : (m.playwright.match ? '⚠' : '·') })}
1306
1312
  \${!m.observer.running && m.playwright.match ? '<div style="padding:10px 14px;border-top:1px solid var(--border);"><button id="restart-observer" class="btn-warn">Restart observer</button> <span class="muted" style="margin-left:10px;font-size:12px;">Re-runs SessionStart/browser-error-daemon.js — spawns Chrome with --cdp-endpoint and registers the daemon pidfile.</span></div>' : ''}
1307
1313
  </div>
@@ -1416,8 +1422,32 @@ document.addEventListener('click', (e) => {
1416
1422
  if (t.id === 'restart-observer') {
1417
1423
  return restartObserverAction(t);
1418
1424
  }
1425
+ if (t.id === 'fix-cdp-port') {
1426
+ return fixCdpPortAction(t);
1427
+ }
1419
1428
  });
1420
1429
 
1430
+ async function fixCdpPortAction(btn) {
1431
+ const originalLabel = btn.textContent;
1432
+ btn.disabled = true;
1433
+ btn.textContent = 'Fixing…';
1434
+ try {
1435
+ const r = await fetch('/api/cdp-port/fix', { method: 'POST' });
1436
+ const result = await r.json();
1437
+ if (r.ok && result.ok) {
1438
+ showToast('CDP port fixed — .mcp.json rewritten. ' + (result.after && result.after.message || ''), 'ok');
1439
+ } else {
1440
+ showToast('Fix failed: ' + (result.error || 'unknown'), 'err');
1441
+ }
1442
+ } catch (e) {
1443
+ showToast('Network error: ' + e.message, 'err');
1444
+ } finally {
1445
+ btn.disabled = false;
1446
+ btn.textContent = originalLabel;
1447
+ await refresh();
1448
+ }
1449
+ }
1450
+
1421
1451
  async function restartObserverAction(btn) {
1422
1452
  const originalLabel = btn.textContent;
1423
1453
  btn.disabled = true;
@@ -1428,7 +1458,16 @@ async function restartObserverAction(btn) {
1428
1458
  if (r.ok && result.ok) {
1429
1459
  showToast('Observer restarted — pid ' + (result.observer && result.observer.pid) + '. Refreshing…', 'ok');
1430
1460
  } else {
1431
- showToast('Restart failed: ' + (result.error || result.script_stderr || 'unknown'), 'err');
1461
+ // The daemon often exits 0 even on failure (it logs the reason to
1462
+ // hooks.log instead of stderr). Surface the richest signal we have.
1463
+ const detail = result.error
1464
+ || (result.recent_log_entry ? result.recent_log_entry.split('\\n').slice(-3).join(' | ') : null)
1465
+ || (result.script_stderr && result.script_stderr.trim())
1466
+ || (result.script_stdout && result.script_stdout.trim())
1467
+ || 'unknown — check .claude/tmp/hooks.log for the most recent SessionStart:browser-error-daemon entry';
1468
+ showToast('Restart failed: ' + detail, 'err');
1469
+ // Also log to browser console for inspection.
1470
+ console.error('[observer-restart] full response:', result);
1432
1471
  }
1433
1472
  } catch (e) {
1434
1473
  showToast('Network error: ' + e.message, 'err');
@@ -1689,11 +1728,38 @@ function handler(cwd) {
1689
1728
  return send(status, JSON.stringify(result), 'application/json');
1690
1729
  }
1691
1730
 
1731
+ if (req.method === 'POST' && route === '/api/cdp-port/fix') {
1732
+ // Re-run checkCdpPortMismatch to get fresh state (the file may have
1733
+ // changed since the panel last fetched). Only apply the fix if the
1734
+ // check still reports severity='mismatch' (i.e. the wrong-port case
1735
+ // — other failure modes like invalid JSON cannot be auto-fixed).
1736
+ const check = checkCdpPortMismatch(cwd);
1737
+ if (check.severity !== 'mismatch') {
1738
+ return send(409, JSON.stringify({
1739
+ ok: false,
1740
+ error: `cdp-port check is "${check.severity}" — only "mismatch" is auto-fixable. Current message: ${check.message}`,
1741
+ check_severity: check.severity,
1742
+ }), 'application/json');
1743
+ }
1744
+ try {
1745
+ applyCdpPortFix(check);
1746
+ } catch (e) {
1747
+ return send(500, JSON.stringify({ ok: false, error: `applyCdpPortFix failed: ${e.message}` }), 'application/json');
1748
+ }
1749
+ const after = checkCdpPortMismatch(cwd);
1750
+ return send(200, JSON.stringify({
1751
+ ok: after.severity === 'ok',
1752
+ before: { severity: check.severity, message: check.message },
1753
+ after: { severity: after.severity, message: after.message },
1754
+ }), 'application/json');
1755
+ }
1756
+
1692
1757
  if (req.method === 'POST' && route === '/api/observer/restart') {
1693
1758
  // Re-run the SessionStart/browser-error-daemon.js hook script
1694
1759
  // synchronously. The script calls ensureObserver() which spawns
1695
1760
  // Chrome with --cdp-endpoint as a detached process. Once the
1696
- // script exits, the daemon is up (or the script logged the failure).
1761
+ // script exits, the daemon is up (or the script logged the failure
1762
+ // to hooks.log even though it exits 0).
1697
1763
  const daemonScript = path.join(cwd, '.claude', 'hooks', 'SessionStart', 'browser-error-daemon.js');
1698
1764
  if (!fs.existsSync(daemonScript)) {
1699
1765
  return send(404, JSON.stringify({ ok: false, error: 'daemon script not found at .claude/hooks/SessionStart/browser-error-daemon.js' }), 'application/json');
@@ -1703,15 +1769,42 @@ function handler(cwd) {
1703
1769
  cwd,
1704
1770
  env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
1705
1771
  encoding: 'utf8',
1706
- timeout: 15000,
1772
+ // Chrome can take 5-10s to spawn cleanly; allow headroom + network probe.
1773
+ timeout: 30000,
1707
1774
  });
1708
- const observerState = readObserverState(cwd, deriveCdpPort(cwd));
1775
+ const observerState = readObserverState(cwd);
1776
+ // Tail the most recent SessionStart entry from hooks.log — the
1777
+ // daemon writes the failure reason there even when it exits 0.
1778
+ let recentLogEntry = null;
1779
+ try {
1780
+ const logPath = path.join(cwd, '.claude', 'tmp', 'hooks.log');
1781
+ const raw = fs.readFileSync(logPath, 'utf8');
1782
+ const lines = raw.split('\n');
1783
+ // Walk backwards, find the most recent block whose hook_file
1784
+ // mentions browser-error-daemon.js.
1785
+ for (let i = lines.length - 1; i >= 0; i--) {
1786
+ if (/browser-error-daemon\.js/.test(lines[i])) {
1787
+ recentLogEntry = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 5)).join('\n');
1788
+ break;
1789
+ }
1790
+ }
1791
+ } catch {}
1709
1792
  const ok = observerState.running;
1793
+ const reason = !ok
1794
+ ? (r.error ? `spawn error: ${r.error.message}` :
1795
+ r.signal ? `script killed by signal ${r.signal} (timed out?)` :
1796
+ r.status !== 0 ? `script exit ${r.status}` :
1797
+ 'script exited 0 but observer pid still not alive — the daemon failed to bind Chrome to the CDP port. See script_stdout + recent_log_entry below.')
1798
+ : null;
1710
1799
  return send(ok ? 200 : 500, JSON.stringify({
1711
1800
  ok,
1712
1801
  observer: observerState,
1713
1802
  script_exit: r.status,
1714
- script_stderr: (r.stderr || '').slice(0, 500),
1803
+ script_signal: r.signal || null,
1804
+ script_stdout: (r.stdout || '').slice(0, 2000),
1805
+ script_stderr: (r.stderr || '').slice(0, 2000),
1806
+ recent_log_entry: recentLogEntry,
1807
+ error: reason,
1715
1808
  }), 'application/json');
1716
1809
  }
1717
1810
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.57",
3
+ "version": "2.13.59",
4
4
  "description": "Claudeflow — AI-powered development toolkit for Claude Code. Skills, agents, hooks, and quality gates that ship production apps.",
5
5
  "bin": {
6
6
  "claudeflow": "./bin/cli.js"