@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.
- package/lib/doctor.js +67 -16
- package/lib/panel.js +89 -12
- 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
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
154
|
+
meta = JSON.parse(fs.readFileSync(observerJsonPath, 'utf8'));
|
|
140
155
|
} catch {
|
|
141
|
-
return { running: false, pid: null,
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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:
|
|
176
|
-
detail: [{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1772
|
+
// Chrome can take 5-10s to spawn cleanly; allow headroom + network probe.
|
|
1773
|
+
timeout: 30000,
|
|
1763
1774
|
});
|
|
1764
|
-
const observerState = readObserverState(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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1812
|
-
server.
|
|
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
|
-
|
|
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.
|
|
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"
|