@axiomatic-labs/claudeflow 2.13.58 → 2.13.60

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.
Files changed (3) hide show
  1. package/lib/doctor.js +67 -16
  2. package/lib/panel.js +89 -12
  3. package/package.json +1 -1
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
@@ -196,7 +196,7 @@ function getMcpInfo(cwd) {
196
196
  const servers = Object.keys(cfg.mcpServers || {});
197
197
  const cdp = checkCdpPortMismatch(cwd);
198
198
  const reading = readPlaywrightCdpEndpoint(mcpPath);
199
- const observer = readObserverState(cwd, deriveCdpPort(cwd));
199
+ const observer = readObserverState(cwd);
200
200
  const lockfiles = checkStaleLockfiles(cwd);
201
201
 
202
202
  return {
@@ -1458,7 +1458,16 @@ async function restartObserverAction(btn) {
1458
1458
  if (r.ok && result.ok) {
1459
1459
  showToast('Observer restarted — pid ' + (result.observer && result.observer.pid) + '. Refreshing…', 'ok');
1460
1460
  } else {
1461
- 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);
1462
1471
  }
1463
1472
  } catch (e) {
1464
1473
  showToast('Network error: ' + e.message, 'err');
@@ -1749,7 +1758,8 @@ function handler(cwd) {
1749
1758
  // Re-run the SessionStart/browser-error-daemon.js hook script
1750
1759
  // synchronously. The script calls ensureObserver() which spawns
1751
1760
  // Chrome with --cdp-endpoint as a detached process. Once the
1752
- // 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).
1753
1763
  const daemonScript = path.join(cwd, '.claude', 'hooks', 'SessionStart', 'browser-error-daemon.js');
1754
1764
  if (!fs.existsSync(daemonScript)) {
1755
1765
  return send(404, JSON.stringify({ ok: false, error: 'daemon script not found at .claude/hooks/SessionStart/browser-error-daemon.js' }), 'application/json');
@@ -1759,15 +1769,42 @@ function handler(cwd) {
1759
1769
  cwd,
1760
1770
  env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
1761
1771
  encoding: 'utf8',
1762
- timeout: 15000,
1772
+ // Chrome can take 5-10s to spawn cleanly; allow headroom + network probe.
1773
+ timeout: 30000,
1763
1774
  });
1764
- 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 {}
1765
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;
1766
1799
  return send(ok ? 200 : 500, JSON.stringify({
1767
1800
  ok,
1768
1801
  observer: observerState,
1769
1802
  script_exit: r.status,
1770
- 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,
1771
1808
  }), 'application/json');
1772
1809
  }
1773
1810
 
@@ -1802,14 +1839,47 @@ function openInBrowser(url) {
1802
1839
  } catch {}
1803
1840
  }
1804
1841
 
1805
- function start({ cwd = process.cwd(), port = 0, openBrowser = true } = {}) {
1842
+ // Derive a stable port from the cwd. Without this, every panel restart picks
1843
+ // a different random port and any open browser tab pointing at the OLD port
1844
+ // becomes a dead bookmark — confusingly, the bookmark keeps rendering the
1845
+ // last-known UI state because /api/status fails silently. Range 9300-9399
1846
+ // (above Chrome CDP's 9200-9299).
1847
+ function derivePanelPort(projectPath) {
1848
+ const crypto = require('crypto');
1849
+ const hash = crypto.createHash('sha1').update(projectPath).digest();
1850
+ return 9300 + (hash.readUInt16BE(0) % 100);
1851
+ }
1852
+
1853
+ function start({ cwd = process.cwd(), port = null, openBrowser = true } = {}) {
1806
1854
  const server = http.createServer(handler(cwd));
1807
1855
  let lastActivity = Date.now();
1808
1856
  server.on('request', () => { lastActivity = Date.now(); });
1809
1857
 
1858
+ // Port resolution:
1859
+ // - explicit port arg (e.g. --port=9999) → use as-is
1860
+ // - port === 0 → OS-assigned random (legacy behavior, retained for tests)
1861
+ // - default (null) → derive from cwd; on EADDRINUSE fall back to OS-assigned
1862
+ const explicitPort = typeof port === 'number' && port > 0;
1863
+ const derivedPort = port === null ? derivePanelPort(cwd) : null;
1864
+ const initialPort = explicitPort ? port : (derivedPort !== null ? derivedPort : 0);
1865
+
1810
1866
  return new Promise((resolve, reject) => {
1811
- server.once('error', reject);
1812
- server.listen(port, '127.0.0.1', () => {
1867
+ let usingFallback = false;
1868
+ server.on('error', (err) => {
1869
+ if (err.code === 'EADDRINUSE' && !usingFallback && !explicitPort) {
1870
+ // Another process holds the derived port. Fall back to OS-assigned
1871
+ // so the panel can still start, but warn the user — they may have
1872
+ // another panel running for this project, or unrelated process took
1873
+ // the port.
1874
+ usingFallback = true;
1875
+ console.error(`\n [panel] derived port ${initialPort} is in use — falling back to OS-assigned port.`);
1876
+ console.error(` [panel] If another \`claudeflow panel\` is running for this project, kill it first to recover URL stability.\n`);
1877
+ server.listen(0, '127.0.0.1');
1878
+ return;
1879
+ }
1880
+ reject(err);
1881
+ });
1882
+ server.on('listening', () => {
1813
1883
  const addr = server.address();
1814
1884
  const url = `http://127.0.0.1:${addr.port}`;
1815
1885
  const idleTimer = setInterval(() => {
@@ -1821,8 +1891,9 @@ function start({ cwd = process.cwd(), port = 0, openBrowser = true } = {}) {
1821
1891
  }, 60 * 1000);
1822
1892
  idleTimer.unref();
1823
1893
  if (openBrowser) openInBrowser(url);
1824
- resolve({ url, server });
1894
+ resolve({ url, server, derivedPort, usedFallback: usingFallback });
1825
1895
  });
1896
+ server.listen(initialPort, '127.0.0.1');
1826
1897
  });
1827
1898
  }
1828
1899
 
@@ -1830,13 +1901,18 @@ async function run(argv = []) {
1830
1901
  const cwd = process.cwd();
1831
1902
  const noOpen = argv.includes('--no-open');
1832
1903
  const portArg = argv.find((a) => a.startsWith('--port='));
1833
- const port = portArg ? parseInt(portArg.split('=')[1], 10) : 0;
1904
+ // Default port (null) derive from cwd so the URL is stable across restarts.
1905
+ // --port=N overrides; --port=0 keeps the legacy OS-assigned behavior.
1906
+ const port = portArg ? parseInt(portArg.split('=')[1], 10) : null;
1834
1907
 
1835
1908
  ui.banner();
1836
1909
  console.log(` Starting panel for ${ui.CYAN}${cwd}${ui.RESET}`);
1837
- const { url } = await start({ cwd, port, openBrowser: !noOpen });
1910
+ const { url, derivedPort, usedFallback } = await start({ cwd, port, openBrowser: !noOpen });
1838
1911
  console.log('');
1839
1912
  console.log(` ${ui.GREEN}▸${ui.RESET} ${ui.CYAN}${url}${ui.RESET}`);
1913
+ if (derivedPort !== null && !usedFallback) {
1914
+ console.log(` ${ui.DIM}Port ${derivedPort} is derived from the cwd — same URL across restarts.${ui.RESET}`);
1915
+ }
1840
1916
  console.log(` ${ui.DIM}Ctrl+C to stop. Auto-shutdown after 30 min idle.${ui.RESET}`);
1841
1917
  console.log('');
1842
1918
  return new Promise(() => {}); // keep alive
@@ -1845,6 +1921,7 @@ async function run(argv = []) {
1845
1921
  module.exports = run;
1846
1922
  module.exports.start = start;
1847
1923
  module.exports.createPanelServer = handler;
1924
+ module.exports.derivePanelPort = derivePanelPort;
1848
1925
  module.exports.collectStatus = collectStatus;
1849
1926
  module.exports.getClaudeMdInfo = getClaudeMdInfo;
1850
1927
  module.exports.getHooksInfo = getHooksInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.58",
3
+ "version": "2.13.60",
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"