@axiomatic-labs/claudeflow 2.33.1 → 2.33.2

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/package.json +8 -3
  2. package/lib/doctor.js +0 -376
  3. package/lib/panel.js +0 -2628
package/lib/panel.js DELETED
@@ -1,2628 +0,0 @@
1
- // `claudeflow panel` — local web dashboard for inspecting claudeflow state.
2
- //
3
- // Spawns a Node http server bound to 127.0.0.1 on a free port, opens the
4
- // system browser, and serves a single-page UI that calls JSON endpoints
5
- // to render: version, CLAUDE.md state, hooks, MCP/observer, setup-context,
6
- // active build run, and doctor checks.
7
- //
8
- // Zero runtime dependencies — Node built-ins only. The HTML/CSS/JS frontend
9
- // lives inline as a string template.
10
-
11
- const http = require('http');
12
- const fs = require('fs');
13
- const path = require('path');
14
- const { spawn } = require('child_process');
15
- const { URL } = require('url');
16
-
17
- const ui = require('./ui.js');
18
- const {
19
- deriveCdpPort,
20
- checkCdpPortMismatch,
21
- checkStaleLockfiles,
22
- checkObserverState,
23
- readObserverState: readObserverStateFromDoctor,
24
- readPlaywrightCdpEndpoint,
25
- applyCdpPortFix,
26
- } = require('./doctor.js');
27
- const {
28
- readOverrides,
29
- toggleHookOverride,
30
- toggleReminderOverride,
31
- toggleEnforcementMode,
32
- toggleRunToCompletionReminder,
33
- toggleCwdLock,
34
- normalizeHandlerPath,
35
- } = require('./hook-overrides.js');
36
-
37
- function loadEnforcementCatalog(cwd) {
38
- const p = path.join(cwd, '.claude', 'hooks', 'shared', 'enforcement-catalog.js');
39
- if (!fs.existsSync(p)) return null;
40
- try {
41
- delete require.cache[require.resolve(p)];
42
- return require(p);
43
- } catch {
44
- return null;
45
- }
46
- }
47
-
48
- // Lazy-load the reminder catalog from the template hook helper. The path
49
- // resolves the same way at install time and from the template repo.
50
- function loadReminderHelpers(cwd) {
51
- const candidates = [
52
- path.join(cwd, '.claude', 'hooks', 'shared', 'reload-reminder.js'),
53
- ];
54
- for (const c of candidates) {
55
- if (fs.existsSync(c)) {
56
- // Bypass require cache so updates to the template are picked up.
57
- delete require.cache[require.resolve(c)];
58
- return require(c);
59
- }
60
- }
61
- return null;
62
- }
63
-
64
- const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min
65
-
66
- // ─── data collectors ─────────────────────────────────────────────
67
-
68
- function getVersionInfo(cwd) {
69
- let installed = null;
70
- for (const rel of [path.join('.claudeflow', 'version'), path.join('.claude', '.claudeflow-version')]) {
71
- try {
72
- installed = fs.readFileSync(path.join(cwd, rel), 'utf8').trim().replace(/^v/, '');
73
- if (installed) break;
74
- } catch {}
75
- }
76
- return { installed: installed || 'unknown' };
77
- }
78
-
79
- function getClaudeMdInfo(cwd) {
80
- const p = path.join(cwd, 'CLAUDE.md');
81
- let stat;
82
- try { stat = fs.statSync(p); } catch { return { exists: false, path: p }; }
83
- const content = fs.readFileSync(p, 'utf8');
84
- const lines = content.split('\n');
85
- const begin = '<!-- claudeflow:default-rules:start';
86
- const end = '<!-- claudeflow:default-rules:end -->';
87
- const beginIdx = content.indexOf(begin);
88
- const endIdx = content.indexOf(end);
89
- let block = null;
90
- let position = null;
91
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
92
- const lineStart = content.slice(0, beginIdx).split('\n').length;
93
- const lineEnd = content.slice(0, endIdx).split('\n').length;
94
- block = { lineStart, lineEnd, bytes: endIdx + end.length - beginIdx };
95
- const totalLines = lines.length;
96
- if (lineStart <= 3) position = 'top';
97
- else if (lineEnd >= totalLines - 3) position = 'bottom';
98
- else position = 'middle';
99
- }
100
- const optOut = content.includes('claudeflow:default-rules:opt-out');
101
- return {
102
- exists: true,
103
- path: p,
104
- bytes: stat.size,
105
- lines: lines.length,
106
- block: block ? { present: true, position, ...block } : { present: false },
107
- optOut,
108
- };
109
- }
110
-
111
- function getAppendPromptInfo(cwd) {
112
- const p = path.join(cwd, 'append-system-prompt.md');
113
- try {
114
- const stat = fs.statSync(p);
115
- return { exists: true, path: p, bytes: stat.size };
116
- } catch {
117
- return { exists: false, path: p };
118
- }
119
- }
120
-
121
- function getHooksInfo(cwd) {
122
- const settingsPath = path.join(cwd, '.claude', 'settings.json');
123
- let settings;
124
- try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); }
125
- catch { return { settingsFound: false, events: [] }; }
126
-
127
- const overrides = readOverrides(cwd);
128
- const disabledIndex = new Map();
129
- for (const d of overrides.disabledHandlers || []) {
130
- disabledIndex.set(`${d.event}|${d.matcher || ''}|${d.handler}`, d);
131
- }
132
- const catalog = loadEnforcementCatalog(cwd);
133
- const isEnforcement = catalog && typeof catalog.isEnforcementHook === 'function'
134
- ? catalog.isEnforcementHook
135
- : () => false;
136
- const enforcementDisabled = overrides.enforcementDisabled === true;
137
-
138
- // settings.json lists every hook (with each command wrapped through
139
- // run-with-override.js). The wrapper checks overrides at invocation time,
140
- // so the panel just annotates each handler with its current disabled
141
- // state from the overrides file.
142
- const hooks = settings.hooks || {};
143
- const events = [];
144
- let totalHandlers = 0;
145
- let warnings = 0;
146
- let disabledCount = 0;
147
-
148
- for (const [event, rules] of Object.entries(hooks)) {
149
- const handlers = [];
150
- for (const rule of rules) {
151
- const matcher = rule.matcher || '';
152
- for (const h of rule.hooks || []) {
153
- const cmd = h.command || '';
154
- const handlerPath = normalizeHandlerPath(cmd) || (cmd ? cmd : '');
155
- const isEmpty = !handlerPath;
156
- if (isEmpty) warnings++;
157
- const key = `${event}|${matcher}|${handlerPath}`;
158
- const enforcement = isEnforcement(event, matcher, handlerPath);
159
- const masterDisabled = enforcement && enforcementDisabled;
160
- const manualDisabled = disabledIndex.has(key);
161
- const isDisabled = manualDisabled || masterDisabled;
162
- if (isDisabled) disabledCount++;
163
- handlers.push({
164
- handler: handlerPath || '(empty command)',
165
- matcher,
166
- matcherDisplay: matcher || '(any)',
167
- empty: isEmpty,
168
- enforcement,
169
- disabled: isDisabled,
170
- disabledBy: masterDisabled && !manualDisabled ? 'enforcement-master'
171
- : manualDisabled ? 'manual'
172
- : null,
173
- rawCommand: cmd,
174
- });
175
- totalHandlers++;
176
- }
177
- }
178
- events.push({ event, count: handlers.length, handlers });
179
- }
180
-
181
- return {
182
- settingsFound: true,
183
- events,
184
- totalHandlers,
185
- activeHandlers: totalHandlers - disabledCount,
186
- disabledHandlers: disabledCount,
187
- warnings,
188
- };
189
- }
190
-
191
- function getMcpInfo(cwd) {
192
- const mcpPath = path.join(cwd, '.mcp.json');
193
- let cfg;
194
- try { cfg = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); }
195
- catch { return { configFound: false }; }
196
-
197
- const servers = Object.keys(cfg.mcpServers || {});
198
- const cdp = checkCdpPortMismatch(cwd);
199
- const reading = readPlaywrightCdpEndpoint(mcpPath);
200
- const observer = readObserverState(cwd);
201
- const lockfiles = checkStaleLockfiles(cwd);
202
-
203
- return {
204
- configFound: true,
205
- servers,
206
- playwright: {
207
- configured: reading.state === 'ok' ? reading.port : null,
208
- computed: deriveCdpPort(cwd),
209
- match: cdp.severity === 'ok',
210
- // `fixable` is true only for the "wrong port" mismatch case — the
211
- // applyCdpPortFix routine can rewrite the args entry. Other cases
212
- // (invalid JSON, unparseable value) can't be auto-fixed by the panel.
213
- fixable: cdp.severity === 'mismatch',
214
- state: reading.state,
215
- message: cdp.message,
216
- },
217
- observer,
218
- staleLockfiles: lockfiles.severity === 'mismatch' ? lockfiles.detail : [],
219
- };
220
- }
221
-
222
- // Re-export the canonical helper from doctor.js so panel callers keep the
223
- // same name. The implementation moved to doctor.js so both surfaces (CLI
224
- // doctor + panel) share a single source of truth.
225
- const readObserverState = readObserverStateFromDoctor;
226
-
227
- function getSetupContextInfo(cwd) {
228
- const p = path.join(cwd, '.claudeflow', 'config', 'setup-context.json');
229
- let ctx;
230
- try { ctx = JSON.parse(fs.readFileSync(p, 'utf8')); }
231
- catch { return { exists: false, path: p }; }
232
-
233
- const canonicalToolingTypes = ['unit_test', 'integration_test', 'api_contract_test', 'e2e_test', 'security_test'];
234
- const tooling = ctx.test_tooling || {};
235
- const toolingComplete = canonicalToolingTypes.every((t) => {
236
- const e = tooling[t];
237
- return e && typeof e === 'object' && typeof e.command_pattern === 'string' && e.command_pattern.trim();
238
- });
239
- const missingTooling = canonicalToolingTypes.filter((t) => {
240
- const e = tooling[t];
241
- return !(e && typeof e === 'object' && e.command_pattern && e.command_pattern.trim());
242
- });
243
-
244
- return {
245
- exists: true,
246
- path: p,
247
- selections: ctx.selections || null,
248
- testTooling: tooling,
249
- toolingComplete,
250
- missingTooling,
251
- };
252
- }
253
-
254
- function getActiveRunInfo(cwd) {
255
- const p = path.join(cwd, '.claudeflow', 'tmp', 'workflow-state.json');
256
- let state;
257
- try { state = JSON.parse(fs.readFileSync(p, 'utf8')); }
258
- catch { return { active: false }; }
259
- const activeRunId = state.active_run;
260
- if (!activeRunId) return { active: false };
261
- const run = (state.runs || {})[activeRunId] || null;
262
- return {
263
- active: true,
264
- runId: activeRunId,
265
- kind: run?.kind || null,
266
- topLevel: run?.top_level || null,
267
- taskCount: run?.tasks ? Object.keys(run.tasks).length : 0,
268
- };
269
- }
270
-
271
- function getEnforcementInfo(cwd) {
272
- const catalog = loadEnforcementCatalog(cwd);
273
- const overrides = readOverrides(cwd);
274
- if (!catalog || !Array.isArray(catalog.ENFORCEMENT_HOOKS)) {
275
- return { available: false, on: !overrides.enforcementDisabled, count: 0 };
276
- }
277
- return {
278
- available: true,
279
- on: !overrides.enforcementDisabled,
280
- count: catalog.ENFORCEMENT_HOOKS.length,
281
- hooks: catalog.ENFORCEMENT_HOOKS.map((h) => ({
282
- event: h.event,
283
- handler: h.handler,
284
- matcher: h.matcher,
285
- })),
286
- };
287
- }
288
-
289
- function getValidatorsInfo(cwd) {
290
- const file = path.join(cwd, '.claudeflow', 'tmp', 'validators-state.json');
291
- let state = null;
292
- try {
293
- const raw = fs.readFileSync(file, 'utf8');
294
- if (raw.trim()) state = JSON.parse(raw);
295
- } catch {}
296
- if (!state || !Array.isArray(state.runs)) {
297
- return { ok: true, available: false, updated_at: null, runs: [], stats: { total: 0, pass: 0, block: 0, skip: 0, error: 0 } };
298
- }
299
- const stats = { total: state.runs.length, pass: 0, block: 0, skip: 0, error: 0 };
300
- for (const r of state.runs) {
301
- if (stats[r.status] !== undefined) stats[r.status] += 1;
302
- }
303
- return {
304
- ok: true,
305
- available: true,
306
- updated_at: state.updated_at || null,
307
- runs: state.runs,
308
- stats,
309
- };
310
- }
311
-
312
- function getObserverInfo(cwd) {
313
- // Read both the canonical state file (full snapshot) AND the summary file
314
- // (which is what hooks consume — has the incident filter applied).
315
- const tmpDir = path.join(cwd, '.claudeflow', 'tmp');
316
- const statePath = path.join(tmpDir, 'error-observer-state.json');
317
- const summaryPath = path.join(tmpDir, 'error-observer-summary.json');
318
- let state;
319
- let summary;
320
- try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { state = null; }
321
- try { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } catch { summary = null; }
322
-
323
- // Daemon liveness reuses doctor.js's helper (same source of truth).
324
- const daemon = readObserverState(cwd);
325
-
326
- // Servers table — flatten into UI-friendly rows.
327
- const servers = state && state.servers && typeof state.servers === 'object'
328
- ? Object.entries(state.servers).map(([label, srv]) => ({
329
- label,
330
- port: srv.port || null,
331
- status: srv.status || 'unknown',
332
- supervised: !!srv.supervised,
333
- observed_external: !!srv.observed_external,
334
- health_ok: srv.health_ok !== false,
335
- last_ready_at: srv.last_ready_at || null,
336
- last_issue_at: srv.last_issue_at || null,
337
- last_exit_at: srv.last_exit_at || null,
338
- last_exit_code: srv.last_exit_code,
339
- last_exit_signal: srv.last_exit_signal,
340
- }))
341
- : [];
342
-
343
- // Browser section.
344
- const browser = state && state.browser ? {
345
- last_snapshot_at: state.browser.last_snapshot_at || null,
346
- latest_url: state.browser.latest_url || null,
347
- latest_route: state.browser.latest_route || null,
348
- latest_origin: state.browser.latest_origin || null,
349
- snapshots_received: state.browser.snapshots_received || 0,
350
- } : null;
351
-
352
- // Unresolved incidents — already filtered in summary, but expose state's
353
- // full list too so the UI can show recent (incl. resolved) history.
354
- const unresolvedIncidents = summary && Array.isArray(summary.unresolved_incidents)
355
- ? summary.unresolved_incidents
356
- : [];
357
- const allInState = state && state.incidents && typeof state.incidents === 'object'
358
- ? Object.values(state.incidents)
359
- .sort((a, b) => String(b.last_seen_at || '').localeCompare(String(a.last_seen_at || '')))
360
- .slice(0, 50)
361
- : [];
362
- // Default surface to UI: ACTIVE only. "Clear observer state" resolves all
363
- // active incidents, so after clear the active list is empty (which matches
364
- // the user's expectation that "Clear" empties the feed). Resolved incidents
365
- // are kept in state.incidents for audit and can be surfaced via the
366
- // "Show resolved" filter pill.
367
- const activeIncidents = allInState.filter((inc) => !inc.resolved_at && inc.active !== false);
368
- const resolvedIncidents = allInState.filter((inc) => inc.resolved_at || inc.active === false);
369
-
370
- return {
371
- daemon,
372
- summary_status: summary ? summary.status : null,
373
- summary_reason: summary ? summary.reason : null,
374
- summary_checked_at: summary ? summary.checked_at : null,
375
- started_at: state ? state.started_at : null,
376
- updated_at: state ? state.updated_at : null,
377
- servers,
378
- browser,
379
- unresolved_incidents: unresolvedIncidents,
380
- active_incidents: activeIncidents,
381
- resolved_incidents: resolvedIncidents,
382
- // Kept for backwards compat with older render code — same as active_incidents.
383
- recent_incidents: activeIncidents,
384
- totals: summary ? {
385
- unresolved: summary.unresolved_incidents_total || 0,
386
- browser: summary.browser_incidents_total || 0,
387
- server: summary.server_incidents_total || 0,
388
- } : null,
389
- };
390
- }
391
-
392
- function getRunToCompletionInfo(cwd) {
393
- const overrides = readOverrides(cwd);
394
- return {
395
- on: overrides.runToCompletionReminderEnabled === true,
396
- handler: 'UserPromptSubmit/run-to-completion-reminder.js',
397
- };
398
- }
399
-
400
- function getCwdLockInfo(cwd) {
401
- const overrides = readOverrides(cwd);
402
- return {
403
- on: overrides.cwdLockEnabled === true,
404
- handler: 'CwdChanged/lock-cwd.js',
405
- };
406
- }
407
-
408
- function getActivePlanInfo(cwd) {
409
- const p = path.join(cwd, '.claudeflow', 'state', 'active-plan.json');
410
- let raw;
411
- try { raw = fs.readFileSync(p, 'utf8'); }
412
- catch { return { available: false }; }
413
- let record;
414
- try { record = JSON.parse(raw); }
415
- catch (err) { return { available: false, error: err.message }; }
416
- const captured = record.captured_at ? Date.parse(record.captured_at) : null;
417
- const ageSeconds = captured ? Math.floor((Date.now() - captured) / 1000) : null;
418
- const content = typeof record.content === 'string' ? record.content : '';
419
- return {
420
- available: true,
421
- path: record.path || null,
422
- source: record.source || null,
423
- sessionId: record.session_id || null,
424
- capturedAt: record.captured_at || null,
425
- ageSeconds,
426
- sha256: record.sha256 || null,
427
- contentBytes: record.contentBytes || content.length,
428
- summaryPreview: content.slice(0, 240).replace(/\s+/g, ' ').trim(),
429
- };
430
- }
431
-
432
- function getRemindersInfo(cwd) {
433
- const helpers = loadReminderHelpers(cwd);
434
- if (!helpers || typeof helpers.listReminderCatalog !== 'function') {
435
- return { available: false, reminders: [], disabledCount: 0 };
436
- }
437
- const catalog = helpers.listReminderCatalog();
438
- const overrides = readOverrides(cwd);
439
- const disabledSet = new Set(overrides.disabledReminders || []);
440
- const reminders = catalog.map((r) => ({
441
- id: r.id,
442
- label: r.label,
443
- description: r.description,
444
- slot: r.slot,
445
- disabled: disabledSet.has(r.id),
446
- }));
447
- return {
448
- available: true,
449
- reminders,
450
- disabledCount: reminders.filter((r) => r.disabled).length,
451
- activeCount: reminders.filter((r) => !r.disabled).length,
452
- };
453
- }
454
-
455
- // ─── token-budget thresholds ─────────────────────────────────────
456
- //
457
- // Anthropic does NOT publish official limits for per-turn additionalContext.
458
- // These defaults are heuristics derived from:
459
- // - GitHub issue anthropics/claude-code#45188 (system prompt 100K+ tokens
460
- // made sessions unusable without /compact)
461
- // - Community guidance: keep injected per-turn content well under 10–15%
462
- // of the 200K context window so user content + tool results have room
463
- // - Practical observation that 5–10K tokens for project rules is common
464
- //
465
- // Override per project via env vars before launching `claudeflow panel`:
466
- // CLAUDEFLOW_TOKEN_HEAVY=8000 (yellow threshold)
467
- // CLAUDEFLOW_TOKEN_HIGH=20000 (red threshold)
468
- const TOKEN_HEAVY_DEFAULT = 8000;
469
- const TOKEN_HIGH_DEFAULT = 20000;
470
-
471
- function tokenThresholds() {
472
- const heavy = parseInt(process.env.CLAUDEFLOW_TOKEN_HEAVY, 10);
473
- const high = parseInt(process.env.CLAUDEFLOW_TOKEN_HIGH, 10);
474
- return {
475
- heavy: Number.isFinite(heavy) && heavy > 0 ? heavy : TOKEN_HEAVY_DEFAULT,
476
- high: Number.isFinite(high) && high > 0 ? high : TOKEN_HIGH_DEFAULT,
477
- };
478
- }
479
-
480
- function classifyTokens(tokens, thresholds = tokenThresholds()) {
481
- if (!tokens || tokens <= 0) return 'none';
482
- if (tokens >= thresholds.high) return 'high';
483
- if (tokens >= thresholds.heavy) return 'heavy';
484
- if (tokens < 2000) return 'light';
485
- return 'normal';
486
- }
487
-
488
- // ─── log events (Logs tab) ───────────────────────────────────────
489
- //
490
- // Sidecar files live in `.claude/tmp/log-events/<id>.json`. Each is one
491
- // event (enforcement-silenced or reminder-injected) with metadata and,
492
- // for reminders, the full content that was injected at fire time. The
493
- // panel reads them via:
494
- // GET /api/logs?limit=&type=&before= → metadata-only paged list
495
- // GET /api/logs/:id → full record incl. content
496
- function logEventsDir(cwd) {
497
- return path.join(cwd, '.claude', 'tmp', 'log-events');
498
- }
499
-
500
- function isValidLogId(id) {
501
- return typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id) && id.length <= 64;
502
- }
503
-
504
- function listLogEvents(cwd, { limit = 200, before = null, type = 'all' } = {}) {
505
- const dir = logEventsDir(cwd);
506
- let files;
507
- try {
508
- files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
509
- } catch {
510
- return { entries: [], total: 0, thresholds: tokenThresholds() };
511
- }
512
- files.sort().reverse(); // filenames begin with sortable timestamp; newest first
513
- const total = files.length;
514
- const thresholds = tokenThresholds();
515
-
516
- let beforeIdx = 0;
517
- if (before) {
518
- const beforeFile = `${before}.json`;
519
- const idx = files.indexOf(beforeFile);
520
- if (idx >= 0) beforeIdx = idx + 1;
521
- }
522
-
523
- const entries = [];
524
- for (let i = beforeIdx; i < files.length && entries.length < limit; i++) {
525
- let r;
526
- try { r = JSON.parse(fs.readFileSync(path.join(dir, files[i]), 'utf8')); }
527
- catch { continue; }
528
- if (type !== 'all') {
529
- if (type === 'enforcement' && !r.type.startsWith('enforcement')) continue;
530
- if (type === 'reminder' && !r.type.startsWith('reminder')) continue;
531
- if (type === 'blocked' && r.type !== 'enforcement-blocked') continue;
532
- }
533
- // Strip large `content` from list view; clients fetch it via /api/logs/:id
534
- // Old sidecars (pre-token-estimate) lack `contentTokens`. Backfill on
535
- // the fly using the same chars/4 heuristic — keeps the UI consistent
536
- // for events captured before the upgrade.
537
- const tokens = typeof r.contentTokens === 'number'
538
- ? r.contentTokens
539
- : (r.contentBytes ? Math.round(r.contentBytes / 4) : 0);
540
- entries.push({
541
- id: r.id,
542
- timestamp: r.timestamp,
543
- type: r.type,
544
- event: r.event,
545
- matcher: r.matcher || '',
546
- handler: r.handler,
547
- summary: r.summary,
548
- contentBytes: r.contentBytes || 0,
549
- contentTokens: tokens,
550
- tokenStatus: classifyTokens(tokens, thresholds),
551
- activeReminderIds: r.activeReminderIds || null,
552
- capturedFromStdout: r.capturedFromStdout === true,
553
- stdoutTruncated: r.stdoutTruncated === true,
554
- parseError: r.parseError || null,
555
- decision: r.decision || null,
556
- reason: r.reason || null,
557
- });
558
- }
559
- return { entries, total, thresholds };
560
- }
561
-
562
- function readLogEvent(cwd, id) {
563
- if (!isValidLogId(id)) return null;
564
- try {
565
- return JSON.parse(fs.readFileSync(path.join(logEventsDir(cwd), `${id}.json`), 'utf8'));
566
- } catch {
567
- return null;
568
- }
569
- }
570
-
571
- // Destructive: removes every sidecar in .claude/tmp/log-events/ and truncates
572
- // .claude/tmp/hooks.log. Returns counts so the UI can confirm the wipe.
573
- function clearLogs(cwd) {
574
- const dir = logEventsDir(cwd);
575
- let removed = 0;
576
- try {
577
- const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
578
- for (const f of files) {
579
- try { fs.unlinkSync(path.join(dir, f)); removed++; } catch {}
580
- }
581
- } catch {}
582
- // Truncate the human-readable log too so the user sees a fresh tail -f.
583
- let logTruncated = false;
584
- const logPath = path.join(cwd, '.claude', 'tmp', 'hooks.log');
585
- try {
586
- if (fs.existsSync(logPath)) {
587
- fs.writeFileSync(logPath, '');
588
- logTruncated = true;
589
- }
590
- } catch {}
591
- return { removed, logTruncated };
592
- }
593
-
594
- function getLogsInfo(cwd) {
595
- const dir = logEventsDir(cwd);
596
- const thresholds = tokenThresholds();
597
- let total = 0;
598
- let mostRecent = null;
599
- let latestReminderTokens = null;
600
- let latestReminderStatus = null;
601
- let recentBlockedCount = 0;
602
- try {
603
- const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
604
- total = files.length;
605
- // Walk from newest. Count blocks within the last 50 events for the
606
- // sidebar severity. Latest-reminder lookup short-circuits when both
607
- // mostRecent + latestReminderTokens are populated AND we've scanned
608
- // enough for the block tally.
609
- const scanCap = Math.min(50, files.length);
610
- for (let i = files.length - 1; i >= files.length - scanCap; i--) {
611
- try {
612
- const r = JSON.parse(fs.readFileSync(path.join(dir, files[i]), 'utf8'));
613
- if (mostRecent === null) mostRecent = r.timestamp;
614
- if (latestReminderTokens === null && r.type && r.type.startsWith('reminder')) {
615
- const tokens = typeof r.contentTokens === 'number'
616
- ? r.contentTokens
617
- : (r.contentBytes ? Math.round(r.contentBytes / 4) : 0);
618
- latestReminderTokens = tokens;
619
- latestReminderStatus = classifyTokens(tokens, thresholds);
620
- }
621
- if (r.type === 'enforcement-blocked') recentBlockedCount++;
622
- } catch {}
623
- }
624
- } catch {}
625
- return {
626
- available: true,
627
- total,
628
- mostRecent,
629
- thresholds,
630
- latestReminderTokens,
631
- latestReminderStatus,
632
- recentBlockedCount,
633
- };
634
- }
635
-
636
- function getDoctorInfo(cwd) {
637
- const checks = [
638
- checkCdpPortMismatch(cwd),
639
- checkStaleLockfiles(cwd),
640
- checkObserverState(cwd),
641
- ];
642
- const issues = checks.filter((c) => c.severity !== 'ok' && c.severity !== 'info');
643
- return {
644
- issueCount: issues.length,
645
- checks: checks.map((c) => ({ id: c.id, severity: c.severity, message: c.message })),
646
- };
647
- }
648
-
649
- function collectStatus(cwd) {
650
- return {
651
- cwd,
652
- timestamp: new Date().toISOString(),
653
- version: getVersionInfo(cwd),
654
- claudeMd: getClaudeMdInfo(cwd),
655
- appendPrompt: getAppendPromptInfo(cwd),
656
- hooks: getHooksInfo(cwd),
657
- mcp: getMcpInfo(cwd),
658
- setupContext: getSetupContextInfo(cwd),
659
- activeRun: getActiveRunInfo(cwd),
660
- reminders: getRemindersInfo(cwd),
661
- enforcement: getEnforcementInfo(cwd),
662
- runToCompletion: getRunToCompletionInfo(cwd),
663
- cwdLock: getCwdLockInfo(cwd),
664
- observer: getObserverInfo(cwd),
665
- validators: getValidatorsInfo(cwd),
666
- activePlan: getActivePlanInfo(cwd),
667
- logs: getLogsInfo(cwd),
668
- doctor: getDoctorInfo(cwd),
669
- };
670
- }
671
-
672
- // ─── frontend (HTML + CSS + JS embedded) ──────────────────────────
673
-
674
- function renderHtml() {
675
- return `<!DOCTYPE html>
676
- <html lang="en">
677
- <head>
678
- <meta charset="utf-8" />
679
- <meta name="viewport" content="width=device-width,initial-scale=1" />
680
- <title>claudeflow panel</title>
681
- <style>
682
- :root {
683
- --bg: #0e1117;
684
- --panel: #161b22;
685
- --panel-2: #1c232c;
686
- --border: #2a313c;
687
- --fg: #e6edf3;
688
- --muted: #8b949e;
689
- --accent: #7c3aed;
690
- --ok: #3fb950;
691
- --warn: #d29922;
692
- --err: #f85149;
693
- --info: #58a6ff;
694
- --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
695
- }
696
- * { box-sizing: border-box; }
697
- body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, sans-serif; }
698
- header { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--panel); position: sticky; top: 0; z-index: 10; }
699
- header .logo { color: var(--accent); font-size: 18px; }
700
- header .title { font-weight: 600; letter-spacing: 0.5px; }
701
- header .version { color: var(--muted); font-family: var(--mono); font-size: 12px; }
702
- header .cwd { color: var(--muted); font-family: var(--mono); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
703
- header .actions { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 12px; }
704
- header button { background: var(--panel-2); border: 1px solid var(--border); color: var(--fg); padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
705
- header button:hover { border-color: var(--accent); }
706
- .enforce-pill { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--panel-2); font-size: 12px; transition: all 0.2s; }
707
- .enforce-pill .enforce-label { color: var(--muted); font-family: var(--mono); letter-spacing: 0.5px; text-transform: uppercase; font-size: 11px; }
708
- .enforce-pill .enforce-state { font-family: var(--mono); font-weight: 600; font-size: 12px; min-width: 26px; text-align: left; }
709
- .enforce-pill[data-state="on"] { border-color: rgba(63, 185, 80, 0.5); background: rgba(63, 185, 80, 0.08); }
710
- .enforce-pill[data-state="on"] .enforce-state { color: var(--ok); }
711
- .enforce-pill[data-state="off"] { border-color: rgba(248, 81, 73, 0.6); background: rgba(248, 81, 73, 0.12); box-shadow: 0 0 0 1px rgba(248, 81, 73, 0.3); }
712
- .enforce-pill[data-state="off"] .enforce-state { color: var(--err); }
713
- .enforce-switch { position: relative; display: inline-block; width: 32px; height: 18px; }
714
- .enforce-switch input { opacity: 0; width: 0; height: 0; }
715
- .enforce-slider { position: absolute; cursor: pointer; inset: 0; background: var(--border); border-radius: 999px; transition: background 0.2s; }
716
- .enforce-slider::before { content: ""; position: absolute; height: 14px; width: 14px; left: 2px; top: 2px; background: var(--fg); border-radius: 50%; transition: transform 0.2s; }
717
- .enforce-switch input:checked + .enforce-slider { background: var(--ok); }
718
- .enforce-switch input:checked + .enforce-slider::before { transform: translateX(14px); }
719
- main { display: flex; min-height: calc(100vh - 56px); }
720
- nav { width: 220px; flex-shrink: 0; border-right: 1px solid var(--border); background: var(--panel); padding: 12px 0; }
721
- nav a { display: flex; align-items: center; gap: 10px; padding: 10px 22px; color: var(--fg); text-decoration: none; cursor: pointer; border-left: 3px solid transparent; font-size: 13px; }
722
- nav a:hover { background: var(--panel-2); }
723
- nav a.active { background: var(--panel-2); border-left-color: var(--accent); }
724
- @media (max-width: 720px) {
725
- main { flex-direction: column; }
726
- nav {
727
- width: 100%; border-right: 0; border-bottom: 1px solid var(--border);
728
- padding: 0; display: flex; overflow-x: auto;
729
- }
730
- nav a {
731
- flex-shrink: 0; padding: 12px 16px; border-left: 0;
732
- border-bottom: 3px solid transparent; white-space: nowrap;
733
- }
734
- nav a.active { border-left: 0; border-bottom-color: var(--accent); }
735
- section { padding: 16px 18px; max-width: none; }
736
- header { padding: 12px 16px; gap: 8px; }
737
- header .cwd { font-size: 11px; }
738
- header .actions { font-size: 11px; }
739
- }
740
- nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
741
- .dot.ok { background: var(--ok); }
742
- .dot.warn { background: var(--warn); }
743
- .dot.err { background: var(--err); }
744
- .dot.info { background: var(--muted); }
745
- section { flex: 1; min-width: 0; padding: 22px 28px; max-width: 980px; }
746
- section h2 { font-size: 16px; margin: 0 0 6px; letter-spacing: 0.3px; }
747
- section .sub { color: var(--muted); font-size: 12px; margin-bottom: 18px; }
748
- .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; margin-bottom: 14px; }
749
- .card-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: center; gap: 10px; }
750
- .card-row:last-child { border-bottom: 0; }
751
- .card-row .k { color: var(--muted); font-size: 13px; }
752
- .card-row .v { font-family: var(--mono); font-size: 13px; }
753
- .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-family: var(--mono); }
754
- .badge.ok { background: rgba(63, 185, 80, 0.12); color: var(--ok); }
755
- .badge.warn { background: rgba(210, 153, 34, 0.12); color: var(--warn); }
756
- .badge.err { background: rgba(248, 81, 73, 0.12); color: var(--err); }
757
- .badge.info { background: rgba(139, 148, 158, 0.16); color: var(--muted); }
758
- pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; overflow: auto; max-height: 480px; font: 12px/1.5 var(--mono); margin: 0; }
759
- details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; }
760
- details summary { cursor: pointer; font-weight: 500; }
761
- details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
762
- .handler { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-family: var(--mono); font-size: 12px; padding: 4px 0; align-items: center; }
763
- .matcher { color: var(--muted); font-size: 11px; }
764
- .muted { color: var(--muted); }
765
- .error { color: var(--err); }
766
- .success { color: var(--ok); }
767
- .warning { color: var(--warn); }
768
- .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
769
- .toggle input { accent-color: var(--accent); }
770
- .disabled-row { opacity: 0.55; }
771
- .disabled-row .matcher { font-style: italic; }
772
- .reminder-row { padding: 10px 0; border-bottom: 1px solid var(--border); }
773
- .reminder-row:last-child { border-bottom: 0; }
774
- .reminder-row .reminder-label { font-size: 13px; }
775
- .reminder-row .reminder-desc { font-size: 12px; margin-left: 22px; margin-top: 2px; }
776
- .reminder-row .reminder-id { font-size: 11px; margin-left: 22px; margin-top: 2px; }
777
- .reminder-row code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-size: 11px; }
778
- .logs-controls { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
779
- .logs-controls select, .logs-controls button { background: var(--panel); border: 1px solid var(--border); color: var(--fg); padding: 6px 10px; border-radius: 6px; font: inherit; }
780
- .logs-controls button:hover { border-color: var(--accent); cursor: pointer; }
781
- .observer-filter { background: var(--panel); border: 1px solid var(--border); color: var(--fg); padding: 4px 10px; border-radius: 999px; cursor: pointer; font-size: 12px; font: inherit; }
782
- .observer-filter:hover { border-color: var(--accent, #6cf); }
783
- .observer-filter.active { background: rgba(108,170,255,0.12); border-color: var(--accent, #6cf); color: var(--accent, #6cf); }
784
- .btn-warn { background: var(--panel); border: 1px solid rgba(245,194,52,0.5); color: var(--warn, #f5c234); padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
785
- .btn-warn:hover { border-color: var(--warn, #f5c234); background: rgba(245,194,52,0.08); }
786
- .btn-warn:disabled { opacity: 0.4; cursor: not-allowed; }
787
- .logs-clear-btn { margin-left: auto; background: var(--panel); color: var(--err); border: 1px solid rgba(248,81,73,0.4); padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; }
788
- .logs-clear-btn:hover { border-color: var(--err); background: rgba(248,81,73,0.08); }
789
- .logs-clear-btn:disabled { opacity: 0.4; cursor: not-allowed; color: var(--muted); border-color: var(--border); }
790
- .logs-clear-btn:disabled:hover { border-color: var(--border); background: var(--panel); }
791
- .logs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
792
- .logs-table th, .logs-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
793
- .logs-table th { font-weight: 500; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
794
- .logs-table tr:hover td { background: rgba(124, 58, 237, 0.05); }
795
- .logs-time { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
796
- .logs-handler { font-family: var(--mono); font-size: 12px; }
797
- .logs-size { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
798
- .logs-view { background: var(--panel-2); border: 1px solid var(--border); color: var(--fg); padding: 4px 12px; border-radius: 5px; font: inherit; font-size: 12px; cursor: pointer; }
799
- .logs-view:hover { border-color: var(--accent); }
800
- .logs-table tr.token-light td:first-child { border-left: 3px solid #3fb950; }
801
- .logs-table tr.token-normal td:first-child { border-left: 3px solid var(--border); }
802
- .logs-table tr.token-heavy td:first-child { border-left: 3px solid var(--warn); }
803
- .logs-table tr.token-high td:first-child { border-left: 3px solid var(--err); }
804
- .logs-table tr.silenced { opacity: 0.78; }
805
- .logs-table tr.silenced .logs-handler { text-decoration: line-through; text-decoration-color: rgba(248,81,73,0.5); }
806
- .logs-status { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
807
- .logs-status.injected { color: #58a6ff; }
808
- .logs-status.silenced { color: var(--err); font-weight: 600; }
809
- .logs-status.blocked { color: var(--err); font-weight: 700; }
810
- .logs-table tr.blocked .logs-handler { color: var(--err); }
811
- .logs-table tr.blocked td:first-child { border-left: 3px solid var(--err) !important; }
812
- .logs-reason-preview { font-size: 11px; color: var(--err); margin-top: 2px; max-width: 480px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
813
- .token-badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 10px; font-family: var(--mono); margin-left: 6px; vertical-align: middle; }
814
- .token-badge.heavy { background: rgba(210,153,34,0.18); color: var(--warn); }
815
- .token-badge.high { background: rgba(248,81,73,0.20); color: var(--err); }
816
- .token-badge.light { background: rgba(63,185,80,0.18); color: var(--ok); }
817
- #log-drawer { display: none; position: fixed; top: 0; right: 0; bottom: 0; width: 60%; max-width: 920px; background: var(--panel); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,0.4); z-index: 100; overflow-y: auto; }
818
- #log-drawer .drawer-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); }
819
- #log-drawer .drawer-head .title { font-weight: 600; }
820
- #log-drawer .drawer-head button { background: none; border: 1px solid var(--border); color: var(--fg); padding: 5px 10px; border-radius: 5px; cursor: pointer; }
821
- #log-drawer .drawer-body { padding: 18px 22px; }
822
- #log-drawer h3 { margin: 0 0 12px; font-size: 15px; }
823
- #log-drawer h4 { margin: 0 0 6px; font-size: 13px; color: var(--muted); }
824
- #log-drawer pre { max-height: none; }
825
- .toast { position: fixed; bottom: 18px; right: 18px; background: var(--panel); border: 1px solid var(--border); padding: 10px 14px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
826
- .toast.err { border-color: var(--err); }
827
- .toast.ok { border-color: var(--ok); }
828
- </style>
829
- </head>
830
- <body>
831
- <header>
832
- <span class="logo">◆</span>
833
- <span class="title">claudeflow panel</span>
834
- <span class="version" id="version">…</span>
835
- <span class="cwd" id="cwd">…</span>
836
- <span class="actions">
837
- <span class="enforce-pill" id="enforce-pill" title="Master switch — toggles every blocking enforcement hook (freeze-step-contract, enforce-task-creation, stack-debt-guard, ecosystem-artifacts, workflow-enforcement). Validators and reminders are unaffected.">
838
- <span class="enforce-label">Enforcement</span>
839
- <label class="enforce-switch"><input type="checkbox" id="enforce-toggle" /><span class="enforce-slider"></span></label>
840
- <span class="enforce-state" id="enforce-state">…</span>
841
- </span>
842
- <span class="enforce-pill" id="run-to-completion-pill" title="Soft nudge — when the agent stops mid-build with PLACEHOLDER proofs or untouched files, the next user prompt receives a reminder telling the LLM to resume without re-checkpointing. Independent of the master Enforcement switch.">
843
- <span class="enforce-label">Run-to-completion reminder</span>
844
- <label class="enforce-switch"><input type="checkbox" id="run-to-completion-toggle" /><span class="enforce-slider"></span></label>
845
- <span class="enforce-state" id="run-to-completion-state">…</span>
846
- </span>
847
- <span class="enforce-pill" id="cwd-lock-pill" title="Hard block — fires on Claude Code CwdChanged event. When the agent runs cd Subdir + cmd and the cwd would persist away from the project root, the change is blocked with a guidance message. Use (cd Subdir + cmd) subshells or absolute paths instead.">
848
- <span class="enforce-label">Lock cwd</span>
849
- <label class="enforce-switch"><input type="checkbox" id="cwd-lock-toggle" /><span class="enforce-slider"></span></label>
850
- <span class="enforce-state" id="cwd-lock-state">…</span>
851
- </span>
852
- <label class="toggle"><input type="checkbox" id="auto" checked /> auto-refresh 5s</label>
853
- <button id="refresh">Refresh</button>
854
- </span>
855
- </header>
856
- <main>
857
- <nav id="nav"></nav>
858
- <section id="content"><p class="muted">Loading…</p></section>
859
- </main>
860
- <aside id="log-drawer">
861
- <div class="drawer-head">
862
- <span class="title">Log event details</span>
863
- <button id="log-drawer-close">Close ✕</button>
864
- </div>
865
- <div class="drawer-body" id="log-drawer-body"></div>
866
- </aside>
867
- <script>
868
- const SECTIONS = [
869
- { id: 'overview', label: 'Overview' },
870
- { id: 'claudeMd', label: 'CLAUDE.md' },
871
- { id: 'hooks', label: 'Hooks' },
872
- { id: 'reminders', label: 'Reminders' },
873
- { id: 'logs', label: 'Logs' },
874
- { id: 'observer', label: 'Observer' },
875
- { id: 'validators', label: 'Validators' },
876
- { id: 'activePlan', label: 'Active plan' },
877
- { id: 'mcp', label: 'MCP & observer' },
878
- { id: 'setupContext', label: 'Setup context' },
879
- { id: 'activeRun', label: 'Active run' },
880
- { id: 'doctor', label: 'Issues' },
881
- ];
882
-
883
- let state = null;
884
- let active = 'overview';
885
- let observerFilter = 'all';
886
- let observerShowResolved = false;
887
- let validatorsFilter = 'all';
888
- let validatorsStatusFilter = 'all';
889
- let validatorsScopeFilter = 'all';
890
- let timer = null;
891
-
892
- // Preserve which <details> were open across re-renders (auto-refresh would
893
- // otherwise collapse them every 5s).
894
- function captureOpenDetails() {
895
- const ids = new Set();
896
- document.querySelectorAll('details[open][data-detail-id]').forEach(d => ids.add(d.dataset.detailId));
897
- // Also capture anonymous details by event name (hooks accordion).
898
- document.querySelectorAll('details[open][data-event]').forEach(d => ids.add('event:' + d.dataset.event));
899
- return ids;
900
- }
901
-
902
- function restoreOpenDetails(ids) {
903
- if (!ids.size) return;
904
- document.querySelectorAll('details[data-detail-id]').forEach(d => {
905
- if (ids.has(d.dataset.detailId)) d.open = true;
906
- });
907
- document.querySelectorAll('details[data-event]').forEach(d => {
908
- if (ids.has('event:' + d.dataset.event)) d.open = true;
909
- });
910
- }
911
-
912
- // Cache fetched CLAUDE.md content so re-renders during auto-refresh don't
913
- // drop the preview the user already loaded.
914
- let claudeMdCache = null;
915
-
916
- async function loadClaudeMdPreview() {
917
- const target = document.getElementById('claude-md-preview');
918
- if (!target) return;
919
- if (target.dataset.loaded === '1') return;
920
- target.dataset.loaded = '1';
921
- target.textContent = 'Loading…';
922
- try {
923
- if (!claudeMdCache) {
924
- const r = await fetch('/api/claude-md');
925
- claudeMdCache = await r.text();
926
- }
927
- const pre = document.createElement('pre');
928
- pre.textContent = claudeMdCache;
929
- target.innerHTML = '';
930
- target.appendChild(pre);
931
- } catch (e) {
932
- target.textContent = 'Failed to load: ' + e.message;
933
- target.dataset.loaded = '';
934
- }
935
- }
936
-
937
- async function refresh() {
938
- const open = captureOpenDetails();
939
- const prevPlanSha = state && state.activePlan && state.activePlan.sha256;
940
- try {
941
- const r = await fetch('/api/status');
942
- state = await r.json();
943
- } catch (e) {
944
- showToast && showToast('Refresh failed: ' + e.message, 'err');
945
- return;
946
- }
947
- // Invalidate cached active-plan body if the underlying sha256 changed.
948
- const newPlanSha = state && state.activePlan && state.activePlan.sha256;
949
- if (newPlanSha !== prevPlanSha) activePlanCache = null;
950
- renderHeader();
951
- renderNav();
952
- renderContent();
953
- restoreOpenDetails(open);
954
- // If preview was visible/open, ensure it stays loaded after re-render.
955
- if (open.has('claude-md-preview')) loadClaudeMdPreview();
956
- if (open.has('active-plan-full')) loadActivePlanPreview();
957
- // Refresh logs entries silently on the Logs tab so new events appear.
958
- if (active === 'logs') loadLogsPage();
959
- }
960
-
961
- function renderHeader() {
962
- document.getElementById('version').textContent = 'v' + state.version.installed;
963
- document.getElementById('cwd').textContent = state.cwd;
964
- const ef = state.enforcement;
965
- const pill = document.getElementById('enforce-pill');
966
- const cb = document.getElementById('enforce-toggle');
967
- const lbl = document.getElementById('enforce-state');
968
- if (ef && ef.available) {
969
- pill.style.display = '';
970
- pill.dataset.state = ef.on ? 'on' : 'off';
971
- cb.checked = ef.on;
972
- lbl.textContent = ef.on ? 'ON' : 'OFF';
973
- pill.title = ef.on
974
- ? 'Enforcement is ON — ' + ef.count + ' blocking hook(s) active. Click to disable all.'
975
- : 'Enforcement is OFF — ' + ef.count + ' blocking hook(s) silenced. Reminders + validators still run.';
976
- } else {
977
- pill.style.display = 'none';
978
- }
979
- const rtc = state.runToCompletion;
980
- const rtcPill = document.getElementById('run-to-completion-pill');
981
- const rtcCb = document.getElementById('run-to-completion-toggle');
982
- const rtcLbl = document.getElementById('run-to-completion-state');
983
- if (rtc && rtcPill) {
984
- rtcPill.dataset.state = rtc.on ? 'on' : 'off';
985
- rtcCb.checked = rtc.on;
986
- rtcLbl.textContent = rtc.on ? 'ON' : 'OFF';
987
- rtcPill.title = rtc.on
988
- ? 'Run-to-completion reminder is ON — every user prompt during an in-progress build step receives a "resume work, do not checkpoint" reminder. The agent can still stop when genuinely blocked.'
989
- : 'Run-to-completion reminder is OFF — the agent may pause for user confirmation after partial progress. Click to enable the soft nudge.';
990
- } else if (rtcPill) {
991
- rtcPill.style.display = 'none';
992
- }
993
- const lock = state.cwdLock;
994
- const lockPill = document.getElementById('cwd-lock-pill');
995
- const lockCb = document.getElementById('cwd-lock-toggle');
996
- const lockLbl = document.getElementById('cwd-lock-state');
997
- if (lock && lockPill) {
998
- lockPill.dataset.state = lock.on ? 'on' : 'off';
999
- lockCb.checked = lock.on;
1000
- lockLbl.textContent = lock.on ? 'ON' : 'OFF';
1001
- lockPill.title = lock.on
1002
- ? 'Lock cwd is ON — any persistent change of working directory away from the project root is blocked via the CwdChanged hook. Use (cd Subdir + cmd) subshells or absolute paths.'
1003
- : 'Lock cwd is OFF — the agent can cd into subdirectories and the cwd will persist across tool calls. Click to enforce a single project-root cwd.';
1004
- } else if (lockPill) {
1005
- lockPill.style.display = 'none';
1006
- }
1007
- }
1008
-
1009
- function severityFor(id) {
1010
- const s = state;
1011
- switch (id) {
1012
- case 'overview': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
1013
- case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
1014
- case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
1015
- case 'reminders': return s.reminders && s.reminders.disabledCount > 0 ? 'info' : 'ok';
1016
- case 'activePlan': return s.activePlan && s.activePlan.available ? 'info' : 'info';
1017
- case 'logs': {
1018
- if (!s.logs) return 'info';
1019
- if (s.logs.recentBlockedCount > 0) return 'err';
1020
- if (s.logs.latestReminderStatus === 'high') return 'err';
1021
- if (s.logs.latestReminderStatus === 'heavy') return 'warn';
1022
- return s.logs.total > 0 ? 'ok' : 'info';
1023
- }
1024
- case 'mcp': {
1025
- if (!s.mcp.configFound) return 'info';
1026
- const playwrightOk = s.mcp.playwright.match && s.mcp.staleLockfiles.length === 0;
1027
- const observerOk = !s.mcp.playwright.match || (s.mcp.observer && s.mcp.observer.running);
1028
- return playwrightOk && observerOk ? 'ok' : 'warn';
1029
- }
1030
- case 'observer': {
1031
- if (!s.observer) return 'info';
1032
- // Daemon down → warn (not err, since the observer is supportive infra).
1033
- if (!s.observer.daemon || !s.observer.daemon.running) return 'warn';
1034
- // Any unresolved incidents → warn so the user sees the badge.
1035
- if (s.observer.totals && s.observer.totals.unresolved > 0) return 'warn';
1036
- // Any supervised server in unhealthy/exited → warn.
1037
- if (s.observer.servers && s.observer.servers.some((sr) => sr.status === 'unhealthy' || sr.status === 'exited')) return 'warn';
1038
- return 'ok';
1039
- }
1040
- case 'validators': {
1041
- if (!s.validators) return 'info';
1042
- // Compute the CURRENT state per (validator, scope) instead of the
1043
- // all-time tally. Runs are stored newest-first, so the first
1044
- // encounter of a given key is the latest run for that combo.
1045
- // Pre-v2.13.85 we used stats.block > 0, which kept the badge amber
1046
- // forever once anything had ever blocked — even after the LLM fixed
1047
- // the underlying bug and subsequent runs passed.
1048
- const seen = new Set();
1049
- let currentlyFailing = 0;
1050
- for (const r of s.validators.runs || []) {
1051
- const key = (r.validator || '') + '|' + (r.scope || '');
1052
- if (seen.has(key)) continue;
1053
- seen.add(key);
1054
- if (r.status === 'block' || r.status === 'error') currentlyFailing += 1;
1055
- }
1056
- if (currentlyFailing > 0) return 'warn';
1057
- // No failing combos — green. Includes "no runs yet" (cleared history
1058
- // or hooks haven't fired) which is conceptually a passing state, not
1059
- // a broken-system state. Pre-v2.13.86 this returned info (gray) when
1060
- // there were 0 runs, which made an empty Validators look "unknown"
1061
- // when it really meant "nothing to worry about".
1062
- return 'ok';
1063
- }
1064
- case 'setupContext': return !s.setupContext.exists ? 'err' : (s.setupContext.toolingComplete ? 'ok' : 'warn');
1065
- case 'activeRun': return s.activeRun.active ? 'info' : 'info';
1066
- case 'doctor': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
1067
- }
1068
- return 'info';
1069
- }
1070
-
1071
- function renderNav() {
1072
- const nav = document.getElementById('nav');
1073
- nav.innerHTML = SECTIONS.map(s => {
1074
- const sev = severityFor(s.id);
1075
- const cls = active === s.id ? 'active' : '';
1076
- return \`<a class="\${cls}" data-id="\${s.id}"><span class="dot \${sev}"></span>\${s.label}</a>\`;
1077
- }).join('');
1078
- nav.querySelectorAll('a').forEach(a => a.onclick = () => { active = a.dataset.id; renderNav(); renderContent(); });
1079
- }
1080
-
1081
- function row(k, v, badge) {
1082
- const b = badge ? \`<span class="badge \${badge.kind}">\${escapeHtml(badge.text)}</span>\` : '';
1083
- return \`<div class="card-row"><span class="k">\${escapeHtml(k)}</span><span class="v">\${b}\${escapeHtml(v)}</span></div>\`;
1084
- }
1085
-
1086
- // Same as row() but the value is rendered as raw HTML (caller is responsible
1087
- // for escaping). Use for values that already contain HTML markup (e.g. <code>).
1088
- function rowHtml(k, vHtml, badge) {
1089
- const b = badge ? \`<span class="badge \${badge.kind}">\${escapeHtml(badge.text)}</span>\` : '';
1090
- return \`<div class="card-row"><span class="k">\${escapeHtml(k)}</span><span class="v">\${b}\${vHtml}</span></div>\`;
1091
- }
1092
-
1093
- function escapeHtml(s) {
1094
- return String(s == null ? '' : s)
1095
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1096
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1097
- }
1098
-
1099
- function fmtBytes(n) {
1100
- if (n == null) return '—';
1101
- if (n < 1024) return n + ' B';
1102
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
1103
- return (n / 1024 / 1024).toFixed(2) + ' MB';
1104
- }
1105
-
1106
- function renderContent() {
1107
- const el = document.getElementById('content');
1108
- if (!state) { el.innerHTML = '<p class="muted">Loading…</p>'; return; }
1109
- const fn = {
1110
- overview: renderOverview,
1111
- claudeMd: renderClaudeMd,
1112
- hooks: renderHooks,
1113
- reminders: renderReminders,
1114
- logs: renderLogs,
1115
- observer: renderObserver,
1116
- validators: renderValidators,
1117
- activePlan: renderActivePlan,
1118
- mcp: renderMcp,
1119
- setupContext: renderSetup,
1120
- activeRun: renderRun,
1121
- doctor: renderDoctor,
1122
- }[active];
1123
- el.innerHTML = fn ? fn() : '';
1124
- }
1125
-
1126
- function renderOverview() {
1127
- const s = state;
1128
- const cm = s.claudeMd;
1129
- const cmText = cm.exists
1130
- ? \`\${fmtBytes(cm.bytes)} / \${cm.lines} lines · block: \${cm.block.present ? cm.block.position : 'absent'}\${cm.optOut ? ' · OPT-OUT' : ''}\`
1131
- : 'missing';
1132
- const ap = s.appendPrompt.exists ? fmtBytes(s.appendPrompt.bytes) : 'missing';
1133
- const hk = \`\${s.hooks.events.length} events · \${s.hooks.totalHandlers} handlers\${s.hooks.warnings ? ' · ' + s.hooks.warnings + ' warnings' : ''}\`;
1134
- const mc = !s.mcp.configFound ? '(no .mcp.json)' :
1135
- \`Playwright \${s.mcp.playwright.match ? '✓' : '✗'} \${s.mcp.playwright.configured || '?'} \${s.mcp.playwright.match ? '' : '(expected ' + s.mcp.playwright.computed + ')'} · observer \${s.mcp.observer.running ? 'running pid '+s.mcp.observer.pid : 'stopped'}\`;
1136
- const sc = !s.setupContext.exists ? 'missing' : (s.setupContext.toolingComplete ? 'complete' : 'incomplete (' + s.setupContext.missingTooling.join(', ') + ')');
1137
- const ar = s.activeRun.active ? s.activeRun.runId : 'none';
1138
- const dc = s.doctor.issueCount === 0 ? '0 issues' : s.doctor.issueCount + ' issue(s) — run \`claudeflow doctor\`';
1139
- const rm = s.reminders && s.reminders.available
1140
- ? \`\${s.reminders.activeCount} active · \${s.reminders.disabledCount} disabled\`
1141
- : 'unavailable';
1142
- const ef = s.enforcement && s.enforcement.available
1143
- ? (s.enforcement.on ? \`ON (\${s.enforcement.count} blocking hooks)\` : \`OFF — \${s.enforcement.count} blocking hooks silenced\`)
1144
- : 'unavailable';
1145
- const activePlanRow = s.activePlan && s.activePlan.available
1146
- ? \`\${s.activePlan.path || '(inline)'} · approved \${s.activePlan.ageSeconds == null ? 'unknown' : (s.activePlan.ageSeconds < 60 ? s.activePlan.ageSeconds + 's' : Math.floor(s.activePlan.ageSeconds / 60) + 'm')} ago · \${fmtBytes(s.activePlan.contentBytes || 0)}\`
1147
- : 'none';
1148
- const reminderTokens = s.logs && s.logs.latestReminderTokens;
1149
- const reminderStatus = s.logs && s.logs.latestReminderStatus;
1150
- const lg = s.logs && s.logs.available
1151
- ? \`\${s.logs.total} captured\${s.logs.mostRecent ? ' · last ' + fmtRelativeTime(s.logs.mostRecent) : ''}\${reminderTokens ? ' · latest reminder ≈' + reminderTokens.toLocaleString() + ' tokens' + (reminderStatus === 'heavy' ? ' ⚠ heavy' : reminderStatus === 'high' ? ' ✗ HIGH' : '') : ''}\`
1152
- : 'no events';
1153
- const logsKind = (() => {
1154
- if (!s.logs || !s.logs.available) return 'info';
1155
- if (reminderStatus === 'high') return 'err';
1156
- if (reminderStatus === 'heavy') return 'warn';
1157
- return s.logs.total > 0 ? 'ok' : 'info';
1158
- })();
1159
- return \`<h2>Overview</h2>
1160
- <p class="sub">Snapshot at \${new Date(s.timestamp).toLocaleTimeString()}</p>
1161
- <div class="card">
1162
- \${row('CLAUDE.md', cmText, { kind: cm.exists ? (cm.optOut ? 'info' : 'ok') : 'err', text: cm.exists ? '✓' : '✗' })}
1163
- \${row('append-system-prompt.md', ap, { kind: s.appendPrompt.exists ? 'ok' : 'err', text: s.appendPrompt.exists ? '✓' : '✗' })}
1164
- \${row('Hooks', hk, { kind: s.hooks.warnings ? 'warn' : 'ok', text: s.hooks.warnings ? '!' : '✓' })}
1165
- \${row('MCP & observer', mc, { kind: !s.mcp.configFound ? 'info' : (s.mcp.playwright.match ? 'ok' : 'warn'), text: !s.mcp.configFound ? '·' : (s.mcp.playwright.match ? '✓' : '!') })}
1166
- \${row('Setup context', sc, { kind: s.setupContext.exists ? (s.setupContext.toolingComplete ? 'ok' : 'warn') : 'err', text: s.setupContext.exists ? (s.setupContext.toolingComplete ? '✓' : '!') : '✗' })}
1167
- \${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
1168
- \${row('Reminders', rm, { kind: s.reminders && s.reminders.available ? (s.reminders.disabledCount ? 'info' : 'ok') : 'err', text: s.reminders && s.reminders.disabledCount ? '·' : '✓' })}
1169
- \${row('Enforcement', ef, { kind: s.enforcement && s.enforcement.available ? (s.enforcement.on ? 'ok' : 'err') : 'err', text: s.enforcement && s.enforcement.available ? (s.enforcement.on ? '✓' : '✗') : '·' })}
1170
- \${row('Active plan', activePlanRow, { kind: s.activePlan && s.activePlan.available ? 'info' : 'info', text: '·' })}
1171
- \${row('Logs', lg, { kind: logsKind, text: logsKind === 'err' ? '✗' : logsKind === 'warn' ? '!' : '·' })}
1172
- \${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
1173
- </div>\`;
1174
- }
1175
-
1176
- function renderClaudeMd() {
1177
- const c = state.claudeMd;
1178
- if (!c.exists) return \`<h2>CLAUDE.md</h2><p class="error">File not found at \${escapeHtml(c.path)}</p>\`;
1179
- const blk = c.block.present
1180
- ? \`present at \${c.block.position} (lines \${c.block.lineStart}–\${c.block.lineEnd}, \${fmtBytes(c.block.bytes)})\`
1181
- : 'absent (no claudeflow:default-rules markers)';
1182
- return \`<h2>CLAUDE.md</h2>
1183
- <p class="sub">\${escapeHtml(c.path)}</p>
1184
- <div class="card">
1185
- \${row('Size', fmtBytes(c.bytes) + ' / ' + c.lines + ' lines')}
1186
- \${row('Default rules block', blk, { kind: c.block.present ? 'ok' : 'info', text: c.block.present ? '✓' : '·' })}
1187
- \${row('Opt-out marker', c.optOut ? 'present (block disabled)' : 'absent', { kind: c.optOut ? 'info' : 'ok', text: c.optOut ? '·' : '✓' })}
1188
- </div>
1189
- <details data-detail-id="claude-md-preview"><summary>Preview content</summary><div id="claude-md-preview" class="muted">Click to load…</div></details>
1190
- \`;
1191
- }
1192
-
1193
- function renderHooks() {
1194
- const h = state.hooks;
1195
- if (!h.settingsFound) return '<h2>Hooks</h2><p class="error">.claude/settings.json not found</p>';
1196
- const items = h.events.map(ev => {
1197
- const handlers = ev.handlers.map(hd => {
1198
- const mark = hd.empty ? '<span class="warning">⚠ empty command</span>' : '';
1199
- const cls = hd.disabled ? 'handler disabled-row' : 'handler';
1200
- const checked = hd.disabled ? '' : 'checked';
1201
- const dataset = \`data-event="\${escapeHtml(ev.event)}" data-matcher="\${escapeHtml(hd.matcher)}" data-handler="\${escapeHtml(hd.handler)}"\`;
1202
- const enforcementBadge = hd.enforcement ? '<span class="badge warn" title="Member of the enforcement catalog — affected by the master switch">enforcement</span>' : '';
1203
- const masterBadge = hd.disabledBy === 'enforcement-master' ? '<span class="badge err" title="Silenced by the master Enforcement OFF switch in the header">master OFF</span>' : '';
1204
- const manualBadge = hd.disabledBy === 'manual' ? '<span class="badge info">disabled</span>' : '';
1205
- const lockedByMaster = hd.disabledBy === 'enforcement-master';
1206
- return \`<div class="\${cls}">
1207
- <label class="toggle"><input type="checkbox" class="hook-toggle" \${dataset} \${checked} \${hd.empty || lockedByMaster ? 'disabled' : ''} />
1208
- <span>\${escapeHtml(hd.handler)} \${enforcementBadge} \${masterBadge} \${manualBadge} \${mark}</span></label>
1209
- <span class="matcher">\${escapeHtml(hd.matcherDisplay)}</span>
1210
- </div>\`;
1211
- }).join('');
1212
- return \`<details data-event="\${escapeHtml(ev.event)}"><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
1213
- }).join('');
1214
- return \`<h2>Hooks</h2>
1215
- <p class="sub">\${h.events.length} events · \${h.activeHandlers} active\${h.disabledHandlers ? ' · <span class="muted">' + h.disabledHandlers + ' disabled</span>' : ''}\${h.warnings ? ' · <span class="warning">' + h.warnings + ' warnings</span>' : ''}</p>
1216
- <p class="sub muted">Uncheck a handler to disable it. Disabled handlers are recorded in <code>.claudeflow/config/user-hook-overrides.json</code> and survive <code>claudeflow update</code>. Toggling rewrites <code>.claude/settings.json</code> immediately; reload Claude Code to pick up the change.</p>
1217
- \${items}\`;
1218
- }
1219
-
1220
- function renderReminders() {
1221
- const r = state.reminders;
1222
- if (!r || !r.available) return '<h2>Reminders</h2><p class="error">Reminder catalog unavailable (template helper not found).</p>';
1223
- const slotLabel = { reads: 'Reads / anchors', rules: 'Rules', 'claude-md': 'CLAUDE.md content' };
1224
- const grouped = {};
1225
- for (const item of r.reminders) {
1226
- if (!grouped[item.slot]) grouped[item.slot] = [];
1227
- grouped[item.slot].push(item);
1228
- }
1229
- const sections = Object.entries(grouped).map(([slot, items]) => {
1230
- const rows = items.map(item => {
1231
- const checked = item.disabled ? '' : 'checked';
1232
- const dataset = \`data-id="\${escapeHtml(item.id)}"\`;
1233
- const status = item.disabled ? '<span class="badge info">disabled</span>' : '';
1234
- const cls = item.disabled ? 'reminder-row disabled-row' : 'reminder-row';
1235
- return \`<div class="\${cls}">
1236
- <label class="toggle"><input type="checkbox" class="reminder-toggle" \${dataset} \${checked} />
1237
- <span class="reminder-label"><strong>\${escapeHtml(item.label)}</strong> \${status}</span></label>
1238
- <div class="reminder-desc muted">\${escapeHtml(item.description)}</div>
1239
- <div class="reminder-id muted">id: <code>\${escapeHtml(item.id)}</code></div>
1240
- </div>\`;
1241
- }).join('');
1242
- return \`<details open><summary>\${escapeHtml(slotLabel[slot] || slot)} <span class="muted">(\${items.length})</span></summary>\${rows}</details>\`;
1243
- }).join('');
1244
- return \`<h2>Reminders</h2>
1245
- <p class="sub">\${r.activeCount} active · \${r.disabledCount} disabled. Toggles are read by <code>reload-reminder.js</code> on every hook invocation — disabling takes effect on the next user prompt without reloading Claude Code.</p>
1246
- \${sections}\`;
1247
- }
1248
-
1249
- // Logs tab — paginated table backed by /api/logs. Lazy-loaded (we don't
1250
- // inline 500 rows into the initial render). The "View" button fetches
1251
- // the full sidecar from /api/logs/:id and renders it in the drawer.
1252
- let logsState = { entries: [], type: 'all', loading: false, allLoaded: false };
1253
-
1254
- async function loadLogsPage({ append = false } = {}) {
1255
- const params = new URLSearchParams({ limit: '100', type: logsState.type });
1256
- if (append && logsState.entries.length > 0) {
1257
- params.set('before', logsState.entries[logsState.entries.length - 1].id);
1258
- }
1259
- logsState.loading = true;
1260
- try {
1261
- const r = await fetch('/api/logs?' + params.toString());
1262
- const j = await r.json();
1263
- if (append) logsState.entries = logsState.entries.concat(j.entries);
1264
- else logsState.entries = j.entries;
1265
- logsState.allLoaded = j.entries.length < 100;
1266
- } catch (e) {
1267
- showToast('Logs fetch failed: ' + e.message, 'err');
1268
- } finally {
1269
- logsState.loading = false;
1270
- if (active === 'logs') renderContent();
1271
- }
1272
- }
1273
-
1274
- async function viewLogDetail(id) {
1275
- const drawer = document.getElementById('log-drawer');
1276
- const body = document.getElementById('log-drawer-body');
1277
- drawer.style.display = 'block';
1278
- body.innerHTML = '<p class="muted">Loading…</p>';
1279
- try {
1280
- const r = await fetch('/api/logs/' + encodeURIComponent(id));
1281
- if (!r.ok) { body.innerHTML = '<p class="error">Event not found.</p>'; return; }
1282
- const ev = await r.json();
1283
- const meta = [
1284
- ['ID', ev.id],
1285
- ['Timestamp (UTC)', ev.timestamp],
1286
- ['Local time', new Date(ev.timestamp).toLocaleString()],
1287
- ['Type', ev.type],
1288
- ['Event', ev.event],
1289
- ['Matcher', ev.matcher || '(any)'],
1290
- ['Handler', ev.handler],
1291
- ];
1292
- let html = '<h3>' + escapeHtml(ev.summary || ev.type) + '</h3>';
1293
- html += '<div class="card">' + meta.map(([k, v]) => row(k, v)).join('') + '</div>';
1294
- if (Array.isArray(ev.activeReminderIds)) {
1295
- html += '<h4 style="margin-top:14px">Active reminders at fire time</h4>';
1296
- html += '<p class="sub">' + ev.activeReminderIds.map(escapeHtml).join(', ') + '</p>';
1297
- }
1298
- // Provenance: did we authentically capture the hook's stdout, or is the
1299
- // record empty / parsed-failed / truncated? Make this visible at the top
1300
- // of the drawer so the user can trust (or distrust) the content shown.
1301
- if (typeof ev.capturedFromStdout === 'boolean') {
1302
- const provBadge = ev.capturedFromStdout
1303
- ? '<span class="badge ok" title="Bytes captured directly from the hook&#39;s stdout — this is the actual additionalContext that Claude Code received.">✓ stdout-captured</span>'
1304
- : (ev.parseError
1305
- ? '<span class="badge err" title="Hook stdout was captured but JSON.parse failed: ' + escapeHtml(ev.parseError) + '">✗ parse failed</span>'
1306
- : '<span class="badge warn" title="No content was captured — the hook may have crashed before writing or returned null.">⚠ no stdout</span>');
1307
- html += '<h4 style="margin-top:14px">Provenance ' + provBadge + (ev.stdoutTruncated ? ' <span class="badge warn">⚠ truncated</span>' : '') + '</h4>';
1308
- }
1309
- // Enforcement-blocked detail: show decision + full reason verbatim.
1310
- if (ev.type === 'enforcement-blocked') {
1311
- html += '<h4 style="margin-top:14px">Decision: <span class="badge err">' + escapeHtml(ev.decision || 'block').toUpperCase() + '</span></h4>';
1312
- if (ev.reason) {
1313
- html += '<h4 style="margin-top:14px">Reason</h4>';
1314
- html += '<pre style="border-color: rgba(248,81,73,0.5)">' + escapeHtml(ev.reason) + '</pre>';
1315
- }
1316
- if (ev.capturedStdout) {
1317
- html += '<details style="margin-top:14px"><summary>Raw stdout (' + fmtBytes(ev.stdoutBytes || 0) + ')</summary><pre>' + escapeHtml(ev.capturedStdout) + '</pre></details>';
1318
- }
1319
- }
1320
- if (typeof ev.contentBytes === 'number' && ev.contentBytes > 0) {
1321
- const tokens = typeof ev.contentTokens === 'number' ? ev.contentTokens : Math.round(ev.contentBytes / 4);
1322
- const th = (state.logs && state.logs.thresholds) || { heavy: 8000, high: 20000 };
1323
- const cls = tokens >= th.high ? 'high' : tokens >= th.heavy ? 'heavy' : tokens < 2000 ? 'light' : 'normal';
1324
- const statusBadge = cls === 'high'
1325
- ? '<span class="token-badge high">⚠ HIGH</span>'
1326
- : cls === 'heavy'
1327
- ? '<span class="token-badge heavy">⚠ HEAVY</span>'
1328
- : '';
1329
- html += '<h4 style="margin-top:14px">Injected content — ≈' + tokens.toLocaleString() + ' tokens ' + statusBadge + ' <span class="muted" style="font-size:11px">(approx · ' + fmtBytes(ev.contentBytes) + ' raw)</span></h4>';
1330
- if (cls === 'heavy' || cls === 'high') {
1331
- const advice = cls === 'high'
1332
- ? 'This reminder exceeds <strong>' + th.high.toLocaleString() + ' tokens</strong> — eats meaningful context budget. Strongly consider:'
1333
- : 'This reminder exceeds <strong>' + th.heavy.toLocaleString() + ' tokens</strong> — heavy. Consider:';
1334
- html += '<div class="card" style="border-color:' + (cls === 'high' ? 'rgba(248,81,73,0.4)' : 'rgba(210,153,34,0.4)') + '"><p>' + advice + '</p>';
1335
- html += '<ul style="margin:6px 0 0 18px; padding:0">';
1336
- html += '<li>Trim CLAUDE.md (currently the largest contributor) — see the CLAUDE.md tab</li>';
1337
- html += '<li>Disable individual reminder IDs you don\\\'t need in the Reminders tab</li>';
1338
- html += '<li>Tune the threshold via <code>CLAUDEFLOW_TOKEN_HEAVY</code> / <code>CLAUDEFLOW_TOKEN_HIGH</code> env vars if your project genuinely needs more</li>';
1339
- html += '</ul></div>';
1340
- }
1341
- }
1342
- if (typeof ev.content === 'string' && ev.content.length > 0) {
1343
- html += '<pre>' + escapeHtml(ev.content) + '</pre>';
1344
- } else if (ev.type === 'enforcement-silenced') {
1345
- html += '<p class="muted">No content — the hook was silenced before running.</p>';
1346
- } else {
1347
- html += '<p class="muted">No captured content.</p>';
1348
- }
1349
- body.innerHTML = html;
1350
- } catch (e) {
1351
- body.innerHTML = '<p class="error">Error: ' + escapeHtml(e.message) + '</p>';
1352
- }
1353
- }
1354
-
1355
- function closeLogDrawer() {
1356
- document.getElementById('log-drawer').style.display = 'none';
1357
- }
1358
-
1359
- function fmtRelativeTime(iso) {
1360
- const ms = Date.now() - new Date(iso).getTime();
1361
- if (ms < 1000) return 'just now';
1362
- if (ms < 60000) return Math.floor(ms / 1000) + 's ago';
1363
- if (ms < 3600000) return Math.floor(ms / 60000) + 'm ago';
1364
- if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ago';
1365
- return Math.floor(ms / 86400000) + 'd ago';
1366
- }
1367
-
1368
- function fmtLocalTime(iso) {
1369
- const d = new Date(iso);
1370
- const pad = (n) => String(n).padStart(2, '0');
1371
- return \`\${d.getFullYear()}-\${pad(d.getMonth() + 1)}-\${pad(d.getDate())} \${pad(d.getHours())}:\${pad(d.getMinutes())}:\${pad(d.getSeconds())}\`;
1372
- }
1373
-
1374
- function renderLogs() {
1375
- // Trigger initial load (or filter change reload) on first render.
1376
- if (logsState.entries.length === 0 && !logsState.loading) {
1377
- loadLogsPage();
1378
- }
1379
- const totalLabel = state.logs && state.logs.total != null ? \`\${state.logs.total} total events\` : '';
1380
- const thresholds = (state.logs && state.logs.thresholds) || { heavy: 8000, high: 20000 };
1381
- const rows = logsState.entries.map((e) => {
1382
- const isReminder = e.type.startsWith('reminder');
1383
- const isSilenced = e.type === 'enforcement-silenced';
1384
- const isBlocked = e.type === 'enforcement-blocked';
1385
- const badge = isReminder
1386
- ? '<span class="badge info" style="background:rgba(88,166,255,0.18); color:#58a6ff">REMINDER</span>'
1387
- : '<span class="badge err">ENFORCEMENT</span>';
1388
- const statusCell = isSilenced
1389
- ? '<span class="logs-status silenced" title="Master Enforcement OFF silenced this hook — it did NOT run. Claude Code received no output for this event.">✗ SILENCED</span>'
1390
- : isBlocked
1391
- ? '<span class="logs-status blocked" title="Hook ran and BLOCKED this action. Claude Code rejected the tool call. Click View for the full reason.">⛔ BLOCKED</span>'
1392
- : '<span class="logs-status injected" title="Hook ran and emitted additionalContext to Claude Code">✓ injected</span>';
1393
- const tokenStatus = e.tokenStatus || 'none';
1394
- const tokenBadge = tokenStatus === 'high'
1395
- ? '<span class="token-badge high" title="High — exceeds the high-water threshold (' + thresholds.high.toLocaleString() + ' tokens). Consider trimming.">⚠ HIGH</span>'
1396
- : tokenStatus === 'heavy'
1397
- ? '<span class="token-badge heavy" title="Heavy — exceeds the heavy threshold (' + thresholds.heavy.toLocaleString() + ' tokens). Consider trimming.">⚠ HEAVY</span>'
1398
- : '';
1399
- const tokensStr = e.contentTokens
1400
- ? \`≈\${e.contentTokens.toLocaleString()}\`
1401
- : '—';
1402
- const bytesStr = e.contentBytes ? fmtBytes(e.contentBytes) : '';
1403
- const tokensTitle = e.contentBytes
1404
- ? \`≈\${e.contentTokens} tokens (chars/4 heuristic) · \${fmtBytes(e.contentBytes)} raw · status: \${tokenStatus}\`
1405
- : isBlocked
1406
- ? 'Hook BLOCKED the action. Click View for full reason.'
1407
- : 'No content (hook was silenced — never ran)';
1408
- const rowCls = \`token-\${tokenStatus}\${isSilenced ? ' silenced' : ''}\${isBlocked ? ' blocked' : ''}\`;
1409
- const reasonPreview = isBlocked && e.reason
1410
- ? \`<div class="logs-reason-preview" title="\${escapeHtml(e.reason)}">\${escapeHtml(e.reason.split('\\n')[0].slice(0, 120))}</div>\`
1411
- : '';
1412
- return \`<tr class="\${rowCls}">
1413
- <td class="logs-time"><span title="\${escapeHtml(e.timestamp)}">\${escapeHtml(fmtLocalTime(e.timestamp))}</span><br><span class="muted" style="font-size:11px">\${fmtRelativeTime(e.timestamp)}</span></td>
1414
- <td>\${badge}</td>
1415
- <td>\${statusCell}</td>
1416
- <td class="logs-handler"><span class="muted">\${escapeHtml(e.event)}</span><br>\${escapeHtml(e.handler)}\${e.matcher ? \` <span class="muted">(\${escapeHtml(e.matcher)})</span>\` : ''}\${reasonPreview}</td>
1417
- <td class="logs-size" title="\${escapeHtml(tokensTitle)}">\${tokensStr}\${tokenBadge}\${bytesStr ? \`<br><span class="muted" style="font-size:11px">\${bytesStr}</span>\` : ''}</td>
1418
- <td><button class="logs-view" data-log-id="\${escapeHtml(e.id)}">View</button></td>
1419
- </tr>\`;
1420
- }).join('');
1421
- const empty = logsState.entries.length === 0
1422
- ? '<p class="muted" style="padding:24px 0">No events captured yet. Open a Claude Code session, send a prompt — reminders + any silenced enforcement hooks will appear here.</p>'
1423
- : '';
1424
- return \`<h2>Logs</h2>
1425
- <p class="sub">Real-time trace of every reminder injection and enforcement-silenced hook. \${totalLabel} · sidecars in <code>.claude/tmp/log-events/</code> (capped at 2000, oldest evicted).</p>
1426
- <p class="sub muted" style="font-size:11px">
1427
- Token-budget thresholds: <strong style="color:var(--warn)">heavy ≥\${thresholds.heavy.toLocaleString()}</strong> · <strong style="color:var(--err)">high ≥\${thresholds.high.toLocaleString()}</strong>.
1428
- Heuristic — Anthropic doesn't publish official limits for per-turn additionalContext.
1429
- Override via <code>CLAUDEFLOW_TOKEN_HEAVY</code> / <code>CLAUDEFLOW_TOKEN_HIGH</code> env vars.
1430
- </p>
1431
- <div class="logs-controls">
1432
- <label>Filter:
1433
- <select id="logs-filter">
1434
- <option value="all" \${logsState.type === 'all' ? 'selected' : ''}>All</option>
1435
- <option value="reminder" \${logsState.type === 'reminder' ? 'selected' : ''}>Reminders only</option>
1436
- <option value="enforcement" \${logsState.type === 'enforcement' ? 'selected' : ''}>Enforcement (silenced + blocked)</option>
1437
- <option value="blocked" \${logsState.type === 'blocked' ? 'selected' : ''}>Blocked actions only</option>
1438
- </select>
1439
- </label>
1440
- <button id="logs-reload">Reload</button>
1441
- <button id="logs-clear" class="logs-clear-btn" \${state.logs && state.logs.total ? '' : 'disabled'} title="Delete every sidecar in .claude/tmp/log-events/ and truncate .claude/tmp/hooks.log. Cannot be undone.">Clear all</button>
1442
- </div>
1443
- \${empty || \`<table class="logs-table">
1444
- <thead><tr><th>Time</th><th>Type</th><th title="Did the hook actually run? ✓ injected = hook ran and emitted to Claude Code · ✗ SILENCED = master Enforcement OFF blocked the hook from running.">Status</th><th>Handler</th><th title="Approximate token count (chars/4 heuristic, ±10–15% vs Claude's real tokenizer). Heavy ≥\${thresholds.heavy.toLocaleString()} → yellow border. High ≥\${thresholds.high.toLocaleString()} → red border. Anthropic does not publish official thresholds for per-turn additionalContext.">≈ Tokens</th><th></th></tr></thead>
1445
- <tbody>\${rows}</tbody>
1446
- </table>\`}
1447
- \${logsState.allLoaded || logsState.entries.length === 0 ? '' : '<div style="margin-top:12px"><button id="logs-load-more">Load older</button></div>'}\`;
1448
- }
1449
-
1450
- function renderActivePlan() {
1451
- const p = state.activePlan;
1452
- if (!p || !p.available) {
1453
- return \`<h2>Active plan</h2>
1454
- <p class="sub">No approved plan on disk. <code>.claudeflow/state/active-plan.json</code> doesn't exist.</p>
1455
- <p class="muted" style="font-size:12px">When you approve a plan via ExitPlanMode in Claude Code, the
1456
- <code>capture-approved-plan</code> hook will persist it here. The next time you run
1457
- <code>claudeflow-build</code>, phase-0 will prompt to use it as the source of truth.</p>\`;
1458
- }
1459
- const age = p.ageSeconds == null
1460
- ? 'unknown age'
1461
- : p.ageSeconds < 60 ? p.ageSeconds + 's ago'
1462
- : p.ageSeconds < 3600 ? Math.floor(p.ageSeconds / 60) + 'm ago'
1463
- : p.ageSeconds < 86400 ? Math.floor(p.ageSeconds / 3600) + 'h ago'
1464
- : Math.floor(p.ageSeconds / 86400) + 'd ago';
1465
- const sha = p.sha256 ? p.sha256.slice(0, 12) + '…' : '(none)';
1466
- const preview = escapeHtml(p.summaryPreview || '(empty)');
1467
- return \`<h2>Active plan</h2>
1468
- <p class="sub">Read-only view of the latest approved plan persisted by the <code>ExitPlanMode</code> hook. The terminal prompt in <code>claudeflow-build</code> phase-0 is the only place you can ACT on this — the panel is informational.</p>
1469
- <div class="card">
1470
- \${row('Path', p.path || '(inline plan, no file path)')}
1471
- \${row('Source', p.source || 'unknown')}
1472
- \${row('Approved', age + ' · ' + (p.capturedAt || 'unknown timestamp'))}
1473
- \${row('Session id', p.sessionId || '(unknown)')}
1474
- \${row('Sha256', sha)}
1475
- \${row('Size', fmtBytes(p.contentBytes || 0))}
1476
- </div>
1477
- <p class="sub" style="margin-top:14px">Preview</p>
1478
- <pre style="max-height:120px">\${preview}</pre>
1479
- <details data-detail-id="active-plan-full"><summary>Full plan content</summary><div id="active-plan-body" class="muted">Click to load…</div></details>\`;
1480
- }
1481
-
1482
- function renderObserver() {
1483
- const o = state.observer;
1484
- if (!o) return '<h2>Observer</h2><p class="muted">No observer info available.</p>';
1485
- const daemonAlive = o.daemon && o.daemon.running;
1486
- const statusBadge = daemonAlive
1487
- ? \`<span class="badge ok">✓ running</span> <span class="muted">pid \${o.daemon.pid} • port \${o.daemon.port} • detected via \${o.daemon.source}</span>\`
1488
- : \`<span class="badge warn">⚠ stopped</span> <span class="muted">no listener detected</span>\`;
1489
- const restartBtn = !daemonAlive
1490
- ? '<button id="restart-observer" class="btn-warn" style="margin-left:14px;">Restart observer</button>'
1491
- : '';
1492
- const totals = o.totals || { unresolved: 0, browser: 0, server: 0 };
1493
- const totalsBadge = totals.unresolved > 0
1494
- ? \`<span class="badge warn">\${totals.unresolved} unresolved</span> <span class="muted">(browser: \${totals.browser}, server: \${totals.server})</span>\`
1495
- : '<span class="badge ok">✓ no unresolved incidents</span>';
1496
- const summaryStatus = o.summary_status ? \`<span class="muted">summary status: <code>\${escapeHtml(o.summary_status)}</code></span>\` : '';
1497
-
1498
- // Servers table
1499
- const serversTable = o.servers.length === 0
1500
- ? '<p class="muted">No servers configured in setup-context.json.servers.</p>'
1501
- : '<table class="kv-table" style="width:100%;border-collapse:collapse;font-size:13px;">' +
1502
- '<thead><tr><th style="text-align:left;padding:6px 8px;">Label</th><th style="text-align:left;padding:6px 8px;">Port</th><th style="text-align:left;padding:6px 8px;">Status</th><th style="text-align:left;padding:6px 8px;">Health</th><th style="text-align:left;padding:6px 8px;">Last ready</th><th style="text-align:left;padding:6px 8px;">Last issue</th></tr></thead>' +
1503
- '<tbody>' +
1504
- o.servers.map((srv) => {
1505
- const statusColor = srv.status === 'running' || srv.status === 'external' ? 'ok' : (srv.status === 'unhealthy' || srv.status === 'exited' ? 'err' : 'info');
1506
- const healthIcon = srv.health_ok ? '<span class="badge ok">✓</span>' : '<span class="badge err">✗</span>';
1507
- const exitInfo = srv.last_exit_code !== null && srv.last_exit_code !== undefined
1508
- ? \` <span class="muted">(exit \${srv.last_exit_code}\${srv.last_exit_signal ? '/' + srv.last_exit_signal : ''})</span>\`
1509
- : '';
1510
- return \`<tr style="border-top:1px solid var(--border);">
1511
- <td style="padding:6px 8px;"><strong>\${escapeHtml(srv.label)}</strong></td>
1512
- <td style="padding:6px 8px;"><code>\${srv.port || '?'}</code></td>
1513
- <td style="padding:6px 8px;"><span class="badge \${statusColor}">\${escapeHtml(srv.status)}</span>\${exitInfo}</td>
1514
- <td style="padding:6px 8px;">\${healthIcon}</td>
1515
- <td style="padding:6px 8px;" class="muted">\${escapeHtml(formatRelativeTime(srv.last_ready_at))}</td>
1516
- <td style="padding:6px 8px;" class="muted">\${escapeHtml(formatRelativeTime(srv.last_issue_at))}</td>
1517
- </tr>\`;
1518
- }).join('') +
1519
- '</tbody></table>';
1520
-
1521
- // Browser section
1522
- const browserBlock = o.browser && o.browser.last_snapshot_at
1523
- ? \`<div class="card" style="padding:10px 14px;font-size:13px;">
1524
- \${rowHtml('Latest URL', o.browser.latest_url ? '<code>' + escapeHtml(o.browser.latest_url) + '</code>' : '<span class="muted">none</span>')}
1525
- \${rowHtml('Latest route', o.browser.latest_route ? '<code>' + escapeHtml(o.browser.latest_route) + '</code>' : '<span class="muted">none</span>')}
1526
- \${rowHtml('Snapshots received', String(o.browser.snapshots_received))}
1527
- \${rowHtml('Last snapshot', escapeHtml(formatRelativeTime(o.browser.last_snapshot_at)))}
1528
- </div>\`
1529
- : '<p class="muted">No browser snapshots received yet. (The observer collects when the agent navigates with mcp__claude-in-chrome__*.)</p>';
1530
-
1531
- // Active vs resolved toggle (client-side state observerShowResolved).
1532
- // After "Clear observer state", all active incidents are marked resolved;
1533
- // by default the UI shows only active. Toggle the pill to see history.
1534
- const showResolved = (typeof observerShowResolved !== 'undefined') && observerShowResolved;
1535
- const sourceList = showResolved ? o.resolved_incidents : o.active_incidents;
1536
-
1537
- // Build the filter index ON THE CURRENT VIEW (active or resolved).
1538
- const serverByOrigin = {};
1539
- o.servers.forEach((srv) => {
1540
- if (srv.port) serverByOrigin[\`localhost:\${srv.port}\`] = srv.label;
1541
- if (srv.port) serverByOrigin[\`127.0.0.1:\${srv.port}\`] = srv.label;
1542
- });
1543
- function incidentBucket(inc) {
1544
- if (inc.source === 'server') {
1545
- return (inc.metadata && inc.metadata.label) || inc.surface || 'server-unknown';
1546
- }
1547
- if (inc.source === 'browser') {
1548
- const url = (inc.metadata && (inc.metadata.url || inc.metadata.origin)) || '';
1549
- const m = url.match(/^https?:\\/\\/([^/]+)/);
1550
- if (m && serverByOrigin[m[1]]) return serverByOrigin[m[1]];
1551
- return 'browser-other';
1552
- }
1553
- return inc.source || 'other';
1554
- }
1555
- const buckets = {};
1556
- sourceList.forEach((inc) => {
1557
- const b = incidentBucket(inc);
1558
- if (!buckets[b]) buckets[b] = [];
1559
- buckets[b].push(inc);
1560
- });
1561
- const surfacesInUse = Object.keys(buckets).sort();
1562
- // Apply observerFilter client-side state. Default 'all' (no filter).
1563
- const filteredIncidents = (typeof observerFilter === 'undefined' || observerFilter === 'all')
1564
- ? sourceList
1565
- : (buckets[observerFilter] || []);
1566
-
1567
- const filterPill = (label, value, count) => {
1568
- const active = (typeof observerFilter === 'undefined' && value === 'all') || observerFilter === value;
1569
- const cls = active ? 'observer-filter active' : 'observer-filter';
1570
- return \`<button class="\${cls}" data-observer-filter="\${escapeHtml(value)}">\${escapeHtml(label)}<span class="muted" style="margin-left:6px;font-size:11px;">\${count}</span></button>\`;
1571
- };
1572
- // Show resolved toggle pill — separate from surface filters.
1573
- const showResolvedClass = showResolved ? 'observer-filter active' : 'observer-filter';
1574
- const showResolvedPill = \`<button class="\${showResolvedClass}" data-observer-show-resolved="toggle" style="margin-left:auto;">\${showResolved ? 'Showing resolved' : 'Show resolved'}<span class="muted" style="margin-left:6px;font-size:11px;">\${o.resolved_incidents.length}</span></button>\`;
1575
- const filterPills = '<div class="observer-filters" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:8px 0;">' +
1576
- filterPill('All', 'all', sourceList.length) +
1577
- surfacesInUse.map((surface) => filterPill(surface, surface, buckets[surface].length)).join('') +
1578
- showResolvedPill +
1579
- '</div>';
1580
-
1581
- // Match file:line patterns in stack traces (most common formats):
1582
- // http://localhost:3001/src/foo.tsx:51:22
1583
- // /Users/abs/path.ts:42
1584
- // at SomeFn (path:line:col)
1585
- function extractFileLineHints(text) {
1586
- if (typeof text !== 'string') return [];
1587
- const hints = new Set();
1588
- // localhost source URLs (Vite/CRA dev server)
1589
- const localRe = /https?:\\/\\/localhost(?::\\d+)?\\/([^\\s:]+?\\.(?:tsx?|jsx?|vue|svelte)):(\\d+)(?::(\\d+))?/g;
1590
- let m;
1591
- while ((m = localRe.exec(text)) !== null) {
1592
- hints.add(\`\${m[1]}:\${m[2]}\${m[3] ? ':' + m[3] : ''}\`);
1593
- if (hints.size >= 6) break;
1594
- }
1595
- // Bare absolute paths
1596
- const absRe = /\\b(\\/[A-Za-z0-9_./-]+?\\.(?:tsx?|jsx?|vue|svelte)):(\\d+)(?::(\\d+))?/g;
1597
- while ((m = absRe.exec(text)) !== null) {
1598
- hints.add(\`\${m[1]}:\${m[2]}\${m[3] ? ':' + m[3] : ''}\`);
1599
- if (hints.size >= 6) break;
1600
- }
1601
- return [...hints];
1602
- }
1603
-
1604
- // Server-side detail (recent_log + exit info) lookup per surface label.
1605
- const serverByLabel = {};
1606
- o.servers.forEach((srv) => { serverByLabel[srv.label] = srv; });
1607
-
1608
- // Incidents feed
1609
- const incidentsBlock = filteredIncidents.length === 0
1610
- ? '<p class="muted">No incidents in this filter.</p>'
1611
- : '<div class="incidents-feed">' +
1612
- filteredIncidents.map((inc) => {
1613
- const sevClass = inc.severity === 'error' || inc.severity === 'critical' ? 'err' : (inc.severity === 'warning' ? 'warn' : 'info');
1614
- const resolvedBadge = inc.resolved_at
1615
- ? '<span class="badge ok" style="margin-left:6px;">resolved</span>'
1616
- : '<span class="badge warn" style="margin-left:6px;">active</span>';
1617
- const surfaceLabel = inc.source === 'server' && inc.metadata && inc.metadata.label
1618
- ? inc.metadata.label
1619
- : inc.surface || inc.source;
1620
- const fullMsg = inc.message || '';
1621
- const fileLineHints = extractFileLineHints(fullMsg);
1622
- // Show first 240 chars by default, collapsible to full message.
1623
- const PREVIEW_CHARS = 240;
1624
- const isLong = fullMsg.length > PREVIEW_CHARS;
1625
- const previewMsg = isLong ? fullMsg.slice(0, PREVIEW_CHARS) + '…' : fullMsg;
1626
- const fullBlock = isLong
1627
- ? '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--muted);font-size:11px;">Show full message + stack (' + fullMsg.length + ' chars)</summary><pre style="font-size:11px;white-space:pre-wrap;background:var(--panel-2);padding:8px;border-radius:4px;margin:4px 0 0 0;overflow:auto;max-height:400px;">' + escapeHtml(fullMsg) + '</pre></details>'
1628
- : '';
1629
- const fileLinesHtml = fileLineHints.length > 0
1630
- ? '<div class="muted" style="font-size:11px;margin-top:4px;">📄 ' + fileLineHints.map((f) => '<code>' + escapeHtml(f) + '</code>').join(' • ') + '</div>'
1631
- : '';
1632
- const urlLine = inc.metadata && inc.metadata.url
1633
- ? '<div class="muted" style="font-size:11px;margin-top:4px;">🌐 <code>' + escapeHtml(inc.metadata.url) + '</code>' + (inc.metadata.route && inc.metadata.route !== inc.metadata.url ? ' (' + escapeHtml(inc.metadata.route) + ')' : '') + '</div>'
1634
- : '';
1635
- // For server incidents, attach recent_log + exit info from the matching server record.
1636
- let serverDetail = '';
1637
- if (inc.source === 'server') {
1638
- const srv = serverByLabel[surfaceLabel];
1639
- if (srv) {
1640
- const exitInfo = srv.last_exit_code !== null && srv.last_exit_code !== undefined
1641
- ? \`<div class="muted" style="font-size:11px;margin-top:4px;">🛑 last exit: code \${srv.last_exit_code}\${srv.last_exit_signal ? ' / signal ' + srv.last_exit_signal : ''} at \${escapeHtml(formatRelativeTime(srv.last_exit_at))}</div>\`
1642
- : '';
1643
- const logLines = Array.isArray(srv.recent_log) ? srv.recent_log : [];
1644
- const logBlock = logLines.length > 0
1645
- ? '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--muted);font-size:11px;">📋 recent_log (' + logLines.length + ' lines from daemon-supervised stdout/stderr)</summary><pre style="font-size:11px;white-space:pre-wrap;background:var(--panel-2);padding:8px;border-radius:4px;margin:4px 0 0 0;overflow:auto;max-height:300px;">' + logLines.slice(-50).map((l) => escapeHtml(typeof l === 'string' ? l : (l.line || JSON.stringify(l)))).join('\\n') + '</pre></details>'
1646
- : '<div class="muted" style="font-size:11px;margin-top:4px;">📋 No recent_log captured (server is observed_external — daemon only port-probes it, doesn\\'t pipe stdout/stderr). Run the server under the daemon supervisor to capture log lines, or check the dev server\\'s own terminal output.</div>';
1647
- serverDetail = exitInfo + logBlock;
1648
- }
1649
- }
1650
- return \`<div class="incident-row" style="padding:10px 14px;border-top:1px solid var(--border);">
1651
- <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
1652
- <span class="badge \${sevClass}">\${escapeHtml(inc.severity || 'info')}</span>
1653
- <span class="muted">\${escapeHtml(inc.source || 'unknown')}</span>
1654
- <span class="muted">→</span>
1655
- <strong>\${escapeHtml(surfaceLabel)}</strong>
1656
- <span class="muted">\${escapeHtml(inc.type || '')}</span>
1657
- \${resolvedBadge}
1658
- <span class="muted" style="margin-left:auto;font-size:11px;">\${escapeHtml(formatRelativeTime(inc.last_seen_at))}</span>
1659
- </div>
1660
- <div style="font-size:12px;white-space:pre-wrap;">\${escapeHtml(previewMsg)}</div>
1661
- \${fullBlock}
1662
- \${fileLinesHtml}
1663
- \${urlLine}
1664
- \${serverDetail}
1665
- </div>\`;
1666
- }).join('') +
1667
- '</div>';
1668
-
1669
- // Clear button — only meaningful when the daemon is running AND there's
1670
- // either at least one unresolved incident or some browser snapshots to wipe.
1671
- const clearable = daemonAlive && (totals.unresolved > 0 || (o.browser && o.browser.snapshots_received > 0) || o.recent_incidents.length > 0);
1672
- const clearBtn = clearable
1673
- ? '<button id="clear-observer" class="logs-clear-btn" style="margin-left:14px;" title="Resolves all active incidents, clears per-server recent_log buffers, and resets browser snapshot counter. The state file keeps incident history (marked resolved). Future events repopulate as usual.">Clear observer state</button>'
1674
- : '';
1675
-
1676
- return \`<h2>Observer</h2>
1677
- <p class="sub">Daemon: \${statusBadge}\${restartBtn}\${clearBtn}</p>
1678
- <p class="sub">Incidents: \${totalsBadge} \${summaryStatus}</p>
1679
- <h3 style="margin-top:18px;font-size:14px;">Servers (\${o.servers.length})</h3>
1680
- <div class="card" style="padding:0;">\${serversTable}</div>
1681
- <h3 style="margin-top:18px;font-size:14px;">Browser channel</h3>
1682
- \${browserBlock}
1683
- <h3 style="margin-top:18px;font-size:14px;">\${showResolved ? 'Resolved incidents (' + o.resolved_incidents.length + ')' : 'Active incidents (' + o.active_incidents.length + ')'}</h3>
1684
- \${filterPills}
1685
- <div class="card" style="padding:0;">\${incidentsBlock}</div>\`;
1686
- }
1687
-
1688
- function renderValidators() {
1689
- const v = (state && state.validators) || { available: false, runs: [], stats: { total: 0, pass: 0, block: 0, skip: 0, error: 0 } };
1690
- if (!v.available || v.runs.length === 0) {
1691
- return \`<h2>Validators</h2><p class="muted">No validator runs recorded yet. The security-scan and validate-file hooks log here on every Stop / SubagentStop / TaskCompleted event.</p>\`;
1692
- }
1693
-
1694
- const validators = [...new Set(v.runs.map((r) => r.validator))].sort();
1695
- const scopes = [...new Set(v.runs.map((r) => r.scope))].sort();
1696
- // Reuse the observer-filter CSS class so the Validators tab matches
1697
- // the visual language of the Observer tab. Pre-v2.13.80 the pills used
1698
- // a non-existent pill class, falling back to default browser button
1699
- // styling — light buttons on dark background.
1700
- const validatorPill = (label, value, count) => {
1701
- const a = (validatorsFilter === value);
1702
- const cls = a ? 'observer-filter active' : 'observer-filter';
1703
- return \`<button class="\${cls}" data-validators-filter="\${escapeHtml(value)}">\${escapeHtml(label)}<span class="muted" style="margin-left:6px;font-size:11px;">\${count}</span></button>\`;
1704
- };
1705
- const statusPill = (label, value, count) => {
1706
- const a = (validatorsStatusFilter === value);
1707
- const cls = a ? 'observer-filter active' : 'observer-filter';
1708
- return \`<button class="\${cls}" data-validators-status-filter="\${escapeHtml(value)}">\${escapeHtml(label)}<span class="muted" style="margin-left:6px;font-size:11px;">\${count}</span></button>\`;
1709
- };
1710
- const scopePill = (label, value, count) => {
1711
- const a = (validatorsScopeFilter === value);
1712
- const cls = a ? 'observer-filter active' : 'observer-filter';
1713
- return \`<button class="\${cls}" data-validators-scope-filter="\${escapeHtml(value)}">\${escapeHtml(label)}<span class="muted" style="margin-left:6px;font-size:11px;">\${count}</span></button>\`;
1714
- };
1715
-
1716
- const filteredByValidator = validatorsFilter === 'all'
1717
- ? v.runs
1718
- : v.runs.filter((r) => r.validator === validatorsFilter);
1719
- const filteredByScope = validatorsScopeFilter === 'all'
1720
- ? filteredByValidator
1721
- : filteredByValidator.filter((r) => r.scope === validatorsScopeFilter);
1722
- const filtered = validatorsStatusFilter === 'all'
1723
- ? filteredByScope
1724
- : filteredByScope.filter((r) => r.status === validatorsStatusFilter);
1725
-
1726
- const STATUS_BADGE = {
1727
- pass: '<span style="color:#3fb950;font-weight:600;">pass</span>',
1728
- block: '<span style="color:#f85149;font-weight:600;">block</span>',
1729
- skip: '<span class="muted">skip</span>',
1730
- error: '<span style="color:#d29922;font-weight:600;">error</span>',
1731
- };
1732
-
1733
- const runRows = filtered.map((r) => {
1734
- const when = formatRelativeTime(r.finished_at || r.started_at);
1735
- const dur = (r.duration_ms && r.duration_ms > 0) ? \` · \${r.duration_ms}ms\` : '';
1736
- const status = STATUS_BADGE[r.status] || escapeHtml(r.status);
1737
- const findingsLine = (r.findings && r.findings.length > 0)
1738
- ? \`<details style="margin-top:6px;"><summary class="muted" style="cursor:pointer;font-size:11px;">\${r.findings_count} finding(s) — show</summary><pre style="margin:6px 0 0 0;font-size:11px;white-space:pre-wrap;">\${escapeHtml(JSON.stringify(r.findings, null, 2))}</pre></details>\`
1739
- : '';
1740
- return \`<div style="border-bottom:1px solid var(--border);padding:10px 12px;">
1741
- <div style="display:flex;justify-content:space-between;gap:12px;">
1742
- <div><strong>\${escapeHtml(r.validator)}</strong> <span class="muted">→</span> \${escapeHtml(r.scope)} <span class="muted" style="margin-left:8px;font-size:11px;">\${escapeHtml(r.hook_event || '')}</span></div>
1743
- <div style="font-size:12px;">\${status} <span class="muted">· \${when}\${dur}</span></div>
1744
- </div>
1745
- <div class="muted" style="font-size:12px;margin-top:4px;">\${escapeHtml(r.reason || '')}</div>
1746
- \${findingsLine}
1747
- </div>\`;
1748
- }).join('');
1749
-
1750
- const validatorPills = [validatorPill('all', 'all', v.runs.length), ...validators.map((vname) =>
1751
- validatorPill(vname, vname, v.runs.filter((r) => r.validator === vname).length)
1752
- )].join(' ');
1753
-
1754
- const scopePills = [scopePill('all', 'all', v.runs.length), ...scopes.map((s) =>
1755
- scopePill(s, s, v.runs.filter((r) => r.scope === s).length)
1756
- )].join(' ');
1757
-
1758
- const statusPills = ['all', 'pass', 'block', 'skip', 'error'].map((s) =>
1759
- statusPill(s, s, s === 'all' ? v.runs.length : v.runs.filter((r) => r.status === s).length)
1760
- ).join(' ');
1761
-
1762
- // Clear button label changes when a scope filter is active so the user
1763
- // sees the action they're about to take. "Clear (Admin-Office)" is
1764
- // unambiguous; "Clear history" wiped everything pre-v2.13.78.
1765
- const clearLabel = validatorsScopeFilter === 'all'
1766
- ? 'Clear all history'
1767
- : \`Clear "\${escapeHtml(validatorsScopeFilter)}" history\`;
1768
-
1769
- const filterRow = (label, pillsHtml) => \`<div style="display:flex;align-items:center;gap:10px;margin:6px 0;flex-wrap:wrap;">
1770
- <span class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;min-width:74px;">\${label}</span>
1771
- <div style="display:flex;gap:6px;flex-wrap:wrap;">\${pillsHtml}</div>
1772
- </div>\`;
1773
-
1774
- // Compute "current state" per (validator, scope) — newest run wins, so
1775
- // the user sees what's failing RIGHT NOW vs what once failed and has
1776
- // since been fixed. The historical totals remain available below.
1777
- const currentBy = new Map();
1778
- for (const r of v.runs) {
1779
- const key = r.validator + '|' + r.scope;
1780
- if (!currentBy.has(key)) currentBy.set(key, r);
1781
- }
1782
- const currentRuns = [...currentBy.values()];
1783
- const currentFailing = currentRuns.filter((r) => r.status === 'block' || r.status === 'error');
1784
- const currentStateLine = currentFailing.length === 0
1785
- ? \`<span style="color:var(--ok,#3fb950);">✓ All \${currentRuns.length} (validator, scope) combo(s) currently passing</span>\`
1786
- : \`<span style="color:var(--err,#f85149);">✗ \${currentFailing.length} of \${currentRuns.length} (validator, scope) combo(s) currently failing:</span> \${currentFailing.map((r) => escapeHtml(r.validator + ' @ ' + r.scope)).join(', ')}\`;
1787
-
1788
- return \`<h2>Validators
1789
- <button id="clear-validators" class="logs-clear-btn" style="float:right;" data-scope="\${escapeHtml(validatorsScopeFilter)}">\${clearLabel}</button>
1790
- </h2>
1791
- <p style="margin-top:0;">\${currentStateLine}</p>
1792
- <p class="muted" style="margin-top:4px;font-size:12px;">History: last updated \${formatRelativeTime(v.updated_at)} · \${v.stats.total} total run(s): \${v.stats.pass} pass, \${v.stats.block} block, \${v.stats.skip} skip, \${v.stats.error} error.</p>
1793
- <div class="card" style="padding:10px 14px;margin:12px 0;">
1794
- \${filterRow('Validator', validatorPills)}
1795
- \${filterRow('Scope', scopePills)}
1796
- \${filterRow('Status', statusPills)}
1797
- </div>
1798
- <div class="card" style="padding:0;">\${runRows || '<p class="muted" style="padding:12px;">No runs match the current filter.</p>'}</div>\`;
1799
- }
1800
-
1801
- function formatRelativeTime(iso) {
1802
- if (!iso) return 'never';
1803
- try {
1804
- const t = new Date(iso).getTime();
1805
- if (Number.isNaN(t)) return 'unknown';
1806
- const diff = Date.now() - t;
1807
- if (diff < 0) return 'in the future';
1808
- if (diff < 60_000) return Math.floor(diff / 1000) + 's ago';
1809
- if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm ago';
1810
- if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h ago';
1811
- return Math.floor(diff / 86_400_000) + 'd ago';
1812
- } catch { return 'unknown'; }
1813
- }
1814
-
1815
- function renderMcp() {
1816
- const m = state.mcp;
1817
- if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
1818
- const lockfiles = m.staleLockfiles.length === 0
1819
- ? '<p class="muted">No stale lockfiles.</p>'
1820
- : '<ul>' + m.staleLockfiles.map(l => \`<li>\${escapeHtml(l.file)} (\${escapeHtml(l.reason)}\${l.pid ? ', pid='+l.pid : ''})</li>\`).join('') + '</ul>';
1821
- return \`<h2>MCP & observer</h2>
1822
- <p class="sub">Servers: \${m.servers.map(escapeHtml).join(', ') || 'none'}</p>
1823
- <div class="card">
1824
- \${row('Playwright --cdp-endpoint port', String(m.playwright.configured || 'n/a'))}
1825
- \${row('Computed port (from path)', String(m.playwright.computed))}
1826
- \${row('Match', m.playwright.match ? 'YES' : 'NO', { kind: m.playwright.match ? 'ok' : 'err', text: m.playwright.match ? '✓' : '✗' })}
1827
- \${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>' : ''}
1828
- \${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 ? '⚠' : '·') })}
1829
- \${!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>' : ''}
1830
- </div>
1831
- <h2 style="margin-top:18px">Stale lockfiles</h2>
1832
- \${lockfiles}\`;
1833
- }
1834
-
1835
- function renderSetup() {
1836
- const s = state.setupContext;
1837
- if (!s.exists) return '<h2>Setup context</h2><p class="error">setup-context.json not found</p>';
1838
- const sel = s.selections ? '<pre>' + escapeHtml(JSON.stringify(s.selections, null, 2)) + '</pre>' : '<p class="muted">No selections.</p>';
1839
- const tooling = '<pre>' + escapeHtml(JSON.stringify(s.testTooling, null, 2)) + '</pre>';
1840
- const missing = s.missingTooling.length ? '<p class="warning">Missing types: ' + s.missingTooling.map(escapeHtml).join(', ') + '</p>' : '<p class="success">All canonical tooling types defined.</p>';
1841
- return \`<h2>Setup context</h2>
1842
- <p class="sub">\${escapeHtml(s.path)}</p>
1843
- <h3>selections</h3>\${sel}
1844
- <h3>test_tooling</h3>\${missing}\${tooling}\`;
1845
- }
1846
-
1847
- function renderRun() {
1848
- const r = state.activeRun;
1849
- if (!r.active) return '<h2>Active run</h2><p class="muted">No active build run.</p>';
1850
- return \`<h2>Active run</h2>
1851
- <div class="card">
1852
- \${row('Run ID', r.runId)}
1853
- \${row('Kind', r.kind || '—')}
1854
- \${row('Top-level subject', (r.topLevel && r.topLevel.subject) || '—')}
1855
- \${row('Tasks tracked', String(r.taskCount))}
1856
- </div>\`;
1857
- }
1858
-
1859
- function renderDoctor() {
1860
- const d = state.doctor;
1861
- return \`<h2>Issues</h2>
1862
- <p class="sub">\${d.issueCount === 0 ? 'No issues detected.' : d.issueCount + ' issue(s) detected. Run <code>claudeflow doctor --fix</code> to repair.'}</p>
1863
- <div class="card">
1864
- \${d.checks.map(c => row(c.id, c.message, { kind: c.severity === 'ok' ? 'ok' : (c.severity === 'info' ? 'info' : 'warn'), text: c.severity })).join('')}
1865
- </div>\`;
1866
- }
1867
-
1868
- function showToast(msg, kind) {
1869
- const el = document.createElement('div');
1870
- el.className = 'toast ' + (kind || 'ok');
1871
- el.textContent = msg;
1872
- document.body.appendChild(el);
1873
- setTimeout(() => el.remove(), 2400);
1874
- }
1875
-
1876
- async function toggleHook(event, matcher, handler, disable) {
1877
- try {
1878
- const r = await fetch('/api/hooks/toggle', {
1879
- method: 'POST',
1880
- headers: { 'Content-Type': 'application/json' },
1881
- body: JSON.stringify({ event, matcher, handler, disable }),
1882
- });
1883
- const result = await r.json();
1884
- if (!r.ok || result.state === 'error') {
1885
- showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
1886
- } else if (result.state === 'noop') {
1887
- showToast('Already in target state (' + result.reason + ')', 'ok');
1888
- } else {
1889
- showToast(disable ? 'Disabled. Reload Claude Code to apply.' : 'Enabled. Reload Claude Code to apply.', 'ok');
1890
- }
1891
- } catch (e) {
1892
- showToast('Network error: ' + e.message, 'err');
1893
- }
1894
- await refresh();
1895
- }
1896
-
1897
- async function toggleReminder(id, disable) {
1898
- try {
1899
- const r = await fetch('/api/reminders/toggle', {
1900
- method: 'POST',
1901
- headers: { 'Content-Type': 'application/json' },
1902
- body: JSON.stringify({ id, disable }),
1903
- });
1904
- const result = await r.json();
1905
- if (!r.ok || result.state === 'error') {
1906
- showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
1907
- } else if (result.state === 'noop') {
1908
- showToast('Already in target state', 'ok');
1909
- } else {
1910
- showToast(disable ? 'Reminder silenced. Next prompt will skip it.' : 'Reminder restored. Next prompt will include it.', 'ok');
1911
- }
1912
- } catch (e) {
1913
- showToast('Network error: ' + e.message, 'err');
1914
- }
1915
- await refresh();
1916
- }
1917
-
1918
- document.addEventListener('click', (e) => {
1919
- const t = e.target;
1920
- if (!t) return;
1921
- if (t.id === 'log-drawer-close') return closeLogDrawer();
1922
- if (t.classList && t.classList.contains('logs-view') && t.dataset.logId) {
1923
- return viewLogDetail(t.dataset.logId);
1924
- }
1925
- if (t.id === 'logs-reload') {
1926
- logsState.entries = [];
1927
- logsState.allLoaded = false;
1928
- return loadLogsPage();
1929
- }
1930
- if (t.id === 'logs-load-more') {
1931
- return loadLogsPage({ append: true });
1932
- }
1933
- if (t.id === 'logs-clear') {
1934
- const total = (state.logs && state.logs.total) || 0;
1935
- if (!total) return;
1936
- if (!confirm('Delete ' + total + ' captured log event(s) and truncate .claude/tmp/hooks.log?\\n\\nThis cannot be undone. (Future events will be captured normally.)')) return;
1937
- return clearLogsAction();
1938
- }
1939
- if (t.id === 'restart-observer') {
1940
- return restartObserverAction(t);
1941
- }
1942
- if (t.id === 'fix-cdp-port') {
1943
- return fixCdpPortAction(t);
1944
- }
1945
- if (t.id === 'clear-observer') {
1946
- return clearObserverAction(t);
1947
- }
1948
- if (t.dataset && t.dataset.observerFilter) {
1949
- observerFilter = t.dataset.observerFilter;
1950
- renderContent();
1951
- return;
1952
- }
1953
- if (t.dataset && t.dataset.observerShowResolved) {
1954
- observerShowResolved = !observerShowResolved;
1955
- // Reset surface filter when toggling, since counts change.
1956
- observerFilter = 'all';
1957
- renderContent();
1958
- return;
1959
- }
1960
- if (t.dataset && t.dataset.validatorsFilter) {
1961
- validatorsFilter = t.dataset.validatorsFilter;
1962
- renderContent();
1963
- return;
1964
- }
1965
- if (t.dataset && t.dataset.validatorsStatusFilter) {
1966
- validatorsStatusFilter = t.dataset.validatorsStatusFilter;
1967
- renderContent();
1968
- return;
1969
- }
1970
- if (t.dataset && t.dataset.validatorsScopeFilter) {
1971
- validatorsScopeFilter = t.dataset.validatorsScopeFilter;
1972
- renderContent();
1973
- return;
1974
- }
1975
- if (t.id === 'clear-validators') {
1976
- return clearValidatorsAction(t);
1977
- }
1978
- });
1979
-
1980
- async function clearValidatorsAction(btn) {
1981
- const scope = (btn.dataset && btn.dataset.scope) || 'all';
1982
- const isScoped = scope !== 'all';
1983
- const message = isScoped
1984
- ? \`Clear validator history for scope "\${scope}"? Runs from other scopes are preserved. Validators continue running.\`
1985
- : 'Clear ALL validator run history? This wipes the entire log. Validators continue running.';
1986
- if (!confirm(message)) return;
1987
- const originalLabel = btn.textContent;
1988
- btn.disabled = true;
1989
- btn.textContent = 'Clearing…';
1990
- try {
1991
- const url = isScoped
1992
- ? '/api/validators/clear?scope=' + encodeURIComponent(scope)
1993
- : '/api/validators/clear';
1994
- const r = await fetch(url, { method: 'POST' });
1995
- const result = await r.json();
1996
- if (r.ok && result.ok) {
1997
- const removed = typeof result.removed === 'number' ? \` (removed \${result.removed})\` : '';
1998
- showToast(isScoped ? \`Cleared "\${scope}"\${removed}\` : \`Cleared all history\${removed}\`, 'ok');
1999
- } else {
2000
- showToast('Clear failed: ' + (result.error || 'unknown'), 'err');
2001
- }
2002
- } catch (e) {
2003
- showToast('Network error: ' + e.message, 'err');
2004
- } finally {
2005
- btn.disabled = false;
2006
- btn.textContent = originalLabel;
2007
- await refresh();
2008
- }
2009
- }
2010
-
2011
- async function clearObserverAction(btn) {
2012
- if (!confirm('Resolve all active observer incidents and clear server logs?\\n\\nIncident history is preserved in state.incidents (marked resolved). Future events repopulate normally. This cannot be undone.')) return;
2013
- const originalLabel = btn.textContent;
2014
- btn.disabled = true;
2015
- btn.textContent = 'Clearing…';
2016
- try {
2017
- const r = await fetch('/api/observer/clear', { method: 'POST' });
2018
- const result = await r.json();
2019
- if (r.ok && result.ok) {
2020
- showToast('Observer state cleared at ' + (result.reset_at || 'now'), 'ok');
2021
- } else {
2022
- showToast('Clear failed: ' + (result.error || 'unknown'), 'err');
2023
- }
2024
- } catch (e) {
2025
- showToast('Network error: ' + e.message, 'err');
2026
- } finally {
2027
- btn.disabled = false;
2028
- btn.textContent = originalLabel;
2029
- await refresh();
2030
- }
2031
- }
2032
-
2033
- async function fixCdpPortAction(btn) {
2034
- const originalLabel = btn.textContent;
2035
- btn.disabled = true;
2036
- btn.textContent = 'Fixing…';
2037
- try {
2038
- const r = await fetch('/api/cdp-port/fix', { method: 'POST' });
2039
- const result = await r.json();
2040
- if (r.ok && result.ok) {
2041
- showToast('CDP port fixed — .mcp.json rewritten. ' + (result.after && result.after.message || ''), 'ok');
2042
- } else {
2043
- showToast('Fix failed: ' + (result.error || 'unknown'), 'err');
2044
- }
2045
- } catch (e) {
2046
- showToast('Network error: ' + e.message, 'err');
2047
- } finally {
2048
- btn.disabled = false;
2049
- btn.textContent = originalLabel;
2050
- await refresh();
2051
- }
2052
- }
2053
-
2054
- async function restartObserverAction(btn) {
2055
- const originalLabel = btn.textContent;
2056
- btn.disabled = true;
2057
- btn.textContent = 'Restarting…';
2058
- try {
2059
- const r = await fetch('/api/observer/restart', { method: 'POST' });
2060
- const result = await r.json();
2061
- if (r.ok && result.ok) {
2062
- showToast('Observer restarted — pid ' + (result.observer && result.observer.pid) + '. Refreshing…', 'ok');
2063
- } else {
2064
- // The daemon often exits 0 even on failure (it logs the reason to
2065
- // hooks.log instead of stderr). Surface the richest signal we have.
2066
- const detail = result.error
2067
- || (result.recent_log_entry ? result.recent_log_entry.split('\\n').slice(-3).join(' | ') : null)
2068
- || (result.script_stderr && result.script_stderr.trim())
2069
- || (result.script_stdout && result.script_stdout.trim())
2070
- || 'unknown — check .claude/tmp/hooks.log for the most recent SessionStart:browser-error-daemon entry';
2071
- showToast('Restart failed: ' + detail, 'err');
2072
- // Also log to browser console for inspection.
2073
- console.error('[observer-restart] full response:', result);
2074
- }
2075
- } catch (e) {
2076
- showToast('Network error: ' + e.message, 'err');
2077
- } finally {
2078
- btn.disabled = false;
2079
- btn.textContent = originalLabel;
2080
- await refresh();
2081
- }
2082
- }
2083
-
2084
- async function clearLogsAction() {
2085
- try {
2086
- const r = await fetch('/api/logs', { method: 'DELETE' });
2087
- const result = await r.json();
2088
- if (!r.ok) {
2089
- showToast('Clear failed: ' + (result.error || r.status), 'err');
2090
- return;
2091
- }
2092
- showToast('Cleared ' + result.removed + ' event(s)' + (result.logTruncated ? ' + truncated hooks.log' : ''), 'ok');
2093
- logsState.entries = [];
2094
- logsState.allLoaded = false;
2095
- await refresh();
2096
- } catch (e) {
2097
- showToast('Network error: ' + e.message, 'err');
2098
- }
2099
- }
2100
-
2101
- document.addEventListener('change', (e) => {
2102
- const t = e.target;
2103
- if (!t || !t.classList) return;
2104
- if (t.id === 'logs-filter') {
2105
- logsState.type = t.value;
2106
- logsState.entries = [];
2107
- logsState.allLoaded = false;
2108
- loadLogsPage();
2109
- return;
2110
- }
2111
- if (t.classList.contains('hook-toggle')) {
2112
- const event = t.dataset.event;
2113
- const matcher = t.dataset.matcher;
2114
- const handler = t.dataset.handler;
2115
- const disable = !t.checked;
2116
- toggleHook(event, matcher, handler, disable);
2117
- } else if (t.classList.contains('reminder-toggle')) {
2118
- const id = t.dataset.id;
2119
- const disable = !t.checked;
2120
- toggleReminder(id, disable);
2121
- }
2122
- });
2123
-
2124
- let activePlanCache = null;
2125
- async function loadActivePlanPreview() {
2126
- const target = document.getElementById('active-plan-body');
2127
- if (!target) return;
2128
- if (target.dataset.loaded === '1') return;
2129
- target.dataset.loaded = '1';
2130
- target.textContent = 'Loading…';
2131
- try {
2132
- if (!activePlanCache) {
2133
- const r = await fetch('/api/active-plan');
2134
- activePlanCache = await r.text();
2135
- }
2136
- const pre = document.createElement('pre');
2137
- pre.textContent = activePlanCache;
2138
- target.innerHTML = '';
2139
- target.appendChild(pre);
2140
- } catch (e) {
2141
- target.textContent = 'Failed to load: ' + e.message;
2142
- target.dataset.loaded = '';
2143
- }
2144
- }
2145
-
2146
- // Lazy-load CLAUDE.md preview when the user opens the details element.
2147
- // The "toggle" event does not bubble by default — capture phase keeps delegation working.
2148
- document.addEventListener('toggle', (e) => {
2149
- const t = e.target;
2150
- if (!t || t.tagName !== 'DETAILS' || !t.open) return;
2151
- if (t.dataset.detailId === 'claude-md-preview') loadClaudeMdPreview();
2152
- else if (t.dataset.detailId === 'active-plan-full') loadActivePlanPreview();
2153
- }, true);
2154
-
2155
- async function toggleEnforcement(disable) {
2156
- try {
2157
- const r = await fetch('/api/enforcement/toggle', {
2158
- method: 'POST',
2159
- headers: { 'Content-Type': 'application/json' },
2160
- body: JSON.stringify({ disable }),
2161
- });
2162
- const result = await r.json();
2163
- if (!r.ok || result.state === 'error') {
2164
- showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
2165
- } else if (result.state === 'noop') {
2166
- showToast('Enforcement already in target state', 'ok');
2167
- } else {
2168
- showToast(disable
2169
- ? 'Enforcement OFF — blocking hooks silenced. Validators + reminders still run.'
2170
- : 'Enforcement ON — full blocking restored.',
2171
- disable ? 'err' : 'ok');
2172
- }
2173
- } catch (e) {
2174
- showToast('Network error: ' + e.message, 'err');
2175
- }
2176
- await refresh();
2177
- }
2178
-
2179
- document.getElementById('enforce-toggle').onchange = (e) => {
2180
- toggleEnforcement(!e.target.checked);
2181
- };
2182
-
2183
- async function toggleRunToCompletion(enable) {
2184
- try {
2185
- const r = await fetch('/api/run-to-completion/toggle', {
2186
- method: 'POST',
2187
- headers: { 'Content-Type': 'application/json' },
2188
- body: JSON.stringify({ enable }),
2189
- });
2190
- const result = await r.json();
2191
- if (!r.ok || result.state === 'error') {
2192
- showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
2193
- } else if (result.state === 'noop') {
2194
- showToast('Run-to-completion reminder already in target state', 'ok');
2195
- } else {
2196
- showToast(enable
2197
- ? 'Run-to-completion reminder ON — agent will be nudged to resume mid-build steps.'
2198
- : 'Run-to-completion reminder OFF — agent may checkpoint after partial progress.',
2199
- 'ok');
2200
- }
2201
- } catch (e) {
2202
- showToast('Network error: ' + e.message, 'err');
2203
- }
2204
- await refresh();
2205
- }
2206
-
2207
- document.getElementById('run-to-completion-toggle').onchange = (e) => {
2208
- toggleRunToCompletion(e.target.checked);
2209
- };
2210
-
2211
- async function toggleCwdLockUI(enable) {
2212
- try {
2213
- const r = await fetch('/api/cwd-lock/toggle', {
2214
- method: 'POST',
2215
- headers: { 'Content-Type': 'application/json' },
2216
- body: JSON.stringify({ enable }),
2217
- });
2218
- const result = await r.json();
2219
- if (!r.ok || result.state === 'error') {
2220
- showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
2221
- } else if (result.state === 'noop') {
2222
- showToast('Cwd lock already in target state', 'ok');
2223
- } else {
2224
- showToast(enable
2225
- ? 'Cwd lock ON — cd away from project root will be BLOCKED on next CwdChanged.'
2226
- : 'Cwd lock OFF — agent can freely change directory.',
2227
- 'ok');
2228
- }
2229
- } catch (e) {
2230
- showToast('Network error: ' + e.message, 'err');
2231
- }
2232
- await refresh();
2233
- }
2234
-
2235
- document.getElementById('cwd-lock-toggle').onchange = (e) => {
2236
- toggleCwdLockUI(e.target.checked);
2237
- };
2238
-
2239
- document.getElementById('refresh').onclick = refresh;
2240
- document.getElementById('auto').onchange = (e) => {
2241
- if (timer) clearInterval(timer);
2242
- if (e.target.checked) timer = setInterval(refresh, 5000);
2243
- };
2244
- timer = setInterval(refresh, 5000);
2245
- refresh();
2246
- </script>
2247
- </body>
2248
- </html>`;
2249
- }
2250
-
2251
- // ─── HTTP server ─────────────────────────────────────────────────
2252
-
2253
- function readClaudeMdSafe(cwd) {
2254
- try { return fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8'); }
2255
- catch { return ''; }
2256
- }
2257
-
2258
- function readRequestBody(req, limit = 64 * 1024) {
2259
- return new Promise((resolve, reject) => {
2260
- const chunks = [];
2261
- let total = 0;
2262
- req.on('data', (c) => {
2263
- total += c.length;
2264
- if (total > limit) {
2265
- reject(new Error('Request body too large'));
2266
- req.destroy();
2267
- return;
2268
- }
2269
- chunks.push(c);
2270
- });
2271
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2272
- req.on('error', reject);
2273
- });
2274
- }
2275
-
2276
- function handler(cwd) {
2277
- return async (req, res) => {
2278
- try {
2279
- const url = new URL(req.url, 'http://localhost');
2280
- const route = url.pathname;
2281
-
2282
- const send = (status, body, type) => {
2283
- res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
2284
- res.end(body);
2285
- };
2286
-
2287
- if (req.method === 'GET') {
2288
- if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
2289
- if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
2290
- if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
2291
- if (route === '/api/active-plan') {
2292
- const p = path.join(cwd, '.claudeflow', 'state', 'active-plan.json');
2293
- try {
2294
- const r = JSON.parse(fs.readFileSync(p, 'utf8'));
2295
- return send(200, r.content || '', 'text/plain; charset=utf-8');
2296
- } catch {
2297
- return send(404, '', 'text/plain');
2298
- }
2299
- }
2300
- if (route === '/api/logs') {
2301
- const limit = Math.min(parseInt(url.searchParams.get('limit'), 10) || 100, 500);
2302
- const before = url.searchParams.get('before') || null;
2303
- const type = url.searchParams.get('type') || 'all';
2304
- return send(200, JSON.stringify(listLogEvents(cwd, { limit, before, type })), 'application/json');
2305
- }
2306
- if (route.startsWith('/api/logs/')) {
2307
- const id = route.slice('/api/logs/'.length);
2308
- const ev = readLogEvent(cwd, id);
2309
- if (!ev) return send(404, JSON.stringify({ error: 'not found' }), 'application/json');
2310
- return send(200, JSON.stringify(ev), 'application/json');
2311
- }
2312
- if (route === '/healthz') return send(200, 'ok', 'text/plain');
2313
- }
2314
-
2315
- if (req.method === 'POST' && route === '/api/hooks/toggle') {
2316
- const raw = await readRequestBody(req);
2317
- let payload;
2318
- try { payload = JSON.parse(raw); }
2319
- catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
2320
- const { event, matcher, handler: hdlr, disable } = payload;
2321
- if (typeof event !== 'string' || typeof hdlr !== 'string' || typeof disable !== 'boolean') {
2322
- return send(400, JSON.stringify({ error: 'event, handler, disable required' }), 'application/json');
2323
- }
2324
- const result = toggleHookOverride(cwd, { event, matcher: matcher || '', handler: hdlr, disable });
2325
- const status = result.state === 'error' ? 500 : 200;
2326
- return send(status, JSON.stringify(result), 'application/json');
2327
- }
2328
-
2329
- if (req.method === 'DELETE' && route === '/api/logs') {
2330
- const result = clearLogs(cwd);
2331
- return send(200, JSON.stringify(result), 'application/json');
2332
- }
2333
-
2334
- if (req.method === 'POST' && route === '/api/enforcement/toggle') {
2335
- const raw = await readRequestBody(req);
2336
- let payload;
2337
- try { payload = JSON.parse(raw); }
2338
- catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
2339
- const { disable } = payload;
2340
- if (typeof disable !== 'boolean') {
2341
- return send(400, JSON.stringify({ error: 'disable required' }), 'application/json');
2342
- }
2343
- const result = toggleEnforcementMode(cwd, { disable });
2344
- const status = result.state === 'error' ? 500 : 200;
2345
- return send(status, JSON.stringify(result), 'application/json');
2346
- }
2347
-
2348
- if (req.method === 'POST' && route === '/api/cwd-lock/toggle') {
2349
- const raw = await readRequestBody(req);
2350
- let payload;
2351
- try { payload = JSON.parse(raw); }
2352
- catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
2353
- const { enable } = payload;
2354
- if (typeof enable !== 'boolean') {
2355
- return send(400, JSON.stringify({ error: 'enable required' }), 'application/json');
2356
- }
2357
- const result = toggleCwdLock(cwd, { enable });
2358
- const status = result.state === 'error' ? 500 : 200;
2359
- return send(status, JSON.stringify(result), 'application/json');
2360
- }
2361
-
2362
- if (req.method === 'POST' && route === '/api/run-to-completion/toggle') {
2363
- const raw = await readRequestBody(req);
2364
- let payload;
2365
- try { payload = JSON.parse(raw); }
2366
- catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
2367
- const { enable } = payload;
2368
- if (typeof enable !== 'boolean') {
2369
- return send(400, JSON.stringify({ error: 'enable required' }), 'application/json');
2370
- }
2371
- const result = toggleRunToCompletionReminder(cwd, { enable });
2372
- const status = result.state === 'error' ? 500 : 200;
2373
- return send(status, JSON.stringify(result), 'application/json');
2374
- }
2375
-
2376
- if (req.method === 'POST' && route === '/api/cdp-port/fix') {
2377
- // Re-run checkCdpPortMismatch to get fresh state (the file may have
2378
- // changed since the panel last fetched). Only apply the fix if the
2379
- // check still reports severity='mismatch' (i.e. the wrong-port case
2380
- // — other failure modes like invalid JSON cannot be auto-fixed).
2381
- const check = checkCdpPortMismatch(cwd);
2382
- if (check.severity !== 'mismatch') {
2383
- return send(409, JSON.stringify({
2384
- ok: false,
2385
- error: `cdp-port check is "${check.severity}" — only "mismatch" is auto-fixable. Current message: ${check.message}`,
2386
- check_severity: check.severity,
2387
- }), 'application/json');
2388
- }
2389
- try {
2390
- applyCdpPortFix(check);
2391
- } catch (e) {
2392
- return send(500, JSON.stringify({ ok: false, error: `applyCdpPortFix failed: ${e.message}` }), 'application/json');
2393
- }
2394
- const after = checkCdpPortMismatch(cwd);
2395
- return send(200, JSON.stringify({
2396
- ok: after.severity === 'ok',
2397
- before: { severity: check.severity, message: check.message },
2398
- after: { severity: after.severity, message: after.message },
2399
- }), 'application/json');
2400
- }
2401
-
2402
- if (req.method === 'POST' && route === '/api/observer/clear') {
2403
- // Forwards to the observer daemon's /reset-session endpoint, which
2404
- // resolves all active browser + server incidents, clears the per-server
2405
- // recent_log buffers, and resets the browser snapshot counter. The
2406
- // history is preserved in state.incidents (marked resolved), so this
2407
- // is a soft clear: future polls won't see the old incidents as active.
2408
- const observer = readObserverState(cwd);
2409
- if (!observer.running) {
2410
- return send(409, JSON.stringify({ ok: false, error: 'observer is not running — start it first, then clear.' }), 'application/json');
2411
- }
2412
- const { request: httpRequest } = require('node:http');
2413
- const result = await new Promise((resolve) => {
2414
- const req2 = httpRequest({
2415
- hostname: '127.0.0.1',
2416
- port: observer.port,
2417
- path: '/reset-session',
2418
- method: 'POST',
2419
- headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
2420
- }, (r) => {
2421
- let body = '';
2422
- r.on('data', (c) => { body += c; });
2423
- r.on('end', () => {
2424
- try { resolve({ status: r.statusCode, body: JSON.parse(body) }); }
2425
- catch { resolve({ status: r.statusCode, body: { raw: body } }); }
2426
- });
2427
- });
2428
- req2.on('error', (err) => resolve({ status: 0, body: { error: err.message } }));
2429
- req2.write('{}');
2430
- req2.end();
2431
- });
2432
- if (result.status === 200 && result.body && result.body.ok) {
2433
- return send(200, JSON.stringify({ ok: true, reset_at: result.body.reset_at }), 'application/json');
2434
- }
2435
- return send(502, JSON.stringify({ ok: false, error: 'observer returned ' + result.status, daemon_response: result.body }), 'application/json');
2436
- }
2437
-
2438
- if (req.method === 'POST' && route === '/api/validators/clear') {
2439
- try {
2440
- const validatorState = require(path.join(cwd, '.claudeflow', 'runtime', 'validator-state.js'));
2441
- const scope = url.searchParams.get('scope');
2442
- const removed = scope && scope !== 'all'
2443
- ? validatorState.clearScope(cwd, scope)
2444
- : (() => { const s = validatorState.readState(cwd); const n = s ? s.runs.length : 0; validatorState.clearState(cwd); return n; })();
2445
- return send(200, JSON.stringify({ ok: true, removed, scope: scope || 'all' }), 'application/json');
2446
- } catch (err) {
2447
- return send(500, JSON.stringify({ ok: false, error: err.message }), 'application/json');
2448
- }
2449
- }
2450
-
2451
- if (req.method === 'POST' && route === '/api/observer/restart') {
2452
- // Re-run the SessionStart/browser-error-daemon.js hook script
2453
- // synchronously. The script calls ensureObserver() which spawns
2454
- // Chrome with --cdp-endpoint as a detached process. Once the
2455
- // script exits, the daemon is up (or the script logged the failure
2456
- // to hooks.log even though it exits 0).
2457
- const daemonScript = path.join(cwd, '.claude', 'hooks', 'SessionStart', 'browser-error-daemon.js');
2458
- if (!fs.existsSync(daemonScript)) {
2459
- return send(404, JSON.stringify({ ok: false, error: 'daemon script not found at .claude/hooks/SessionStart/browser-error-daemon.js' }), 'application/json');
2460
- }
2461
- const { spawnSync } = require('child_process');
2462
- const r = spawnSync(process.execPath, [daemonScript], {
2463
- cwd,
2464
- env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
2465
- encoding: 'utf8',
2466
- // Chrome can take 5-10s to spawn cleanly; allow headroom + network probe.
2467
- timeout: 30000,
2468
- });
2469
- const observerState = readObserverState(cwd);
2470
- // Tail the most recent SessionStart entry from hooks.log — the
2471
- // daemon writes the failure reason there even when it exits 0.
2472
- let recentLogEntry = null;
2473
- try {
2474
- const logPath = path.join(cwd, '.claude', 'tmp', 'hooks.log');
2475
- const raw = fs.readFileSync(logPath, 'utf8');
2476
- const lines = raw.split('\n');
2477
- // Walk backwards, find the most recent block whose hook_file
2478
- // mentions browser-error-daemon.js.
2479
- for (let i = lines.length - 1; i >= 0; i--) {
2480
- if (/browser-error-daemon\.js/.test(lines[i])) {
2481
- recentLogEntry = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 5)).join('\n');
2482
- break;
2483
- }
2484
- }
2485
- } catch {}
2486
- const ok = observerState.running;
2487
- const reason = !ok
2488
- ? (r.error ? `spawn error: ${r.error.message}` :
2489
- r.signal ? `script killed by signal ${r.signal} (timed out?)` :
2490
- r.status !== 0 ? `script exit ${r.status}` :
2491
- '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.')
2492
- : null;
2493
- return send(ok ? 200 : 500, JSON.stringify({
2494
- ok,
2495
- observer: observerState,
2496
- script_exit: r.status,
2497
- script_signal: r.signal || null,
2498
- script_stdout: (r.stdout || '').slice(0, 2000),
2499
- script_stderr: (r.stderr || '').slice(0, 2000),
2500
- recent_log_entry: recentLogEntry,
2501
- error: reason,
2502
- }), 'application/json');
2503
- }
2504
-
2505
- if (req.method === 'POST' && route === '/api/reminders/toggle') {
2506
- const raw = await readRequestBody(req);
2507
- let payload;
2508
- try { payload = JSON.parse(raw); }
2509
- catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
2510
- const { id, disable } = payload;
2511
- if (typeof id !== 'string' || typeof disable !== 'boolean') {
2512
- return send(400, JSON.stringify({ error: 'id, disable required' }), 'application/json');
2513
- }
2514
- const result = toggleReminderOverride(cwd, { id, disable });
2515
- const status = result.state === 'error' ? 500 : 200;
2516
- return send(status, JSON.stringify(result), 'application/json');
2517
- }
2518
-
2519
- send(404, JSON.stringify({ error: 'not found' }), 'application/json');
2520
- } catch (err) {
2521
- res.writeHead(500, { 'Content-Type': 'application/json' });
2522
- res.end(JSON.stringify({ error: err.message }));
2523
- }
2524
- };
2525
- }
2526
-
2527
- function openInBrowser(url) {
2528
- const cmd = process.platform === 'darwin' ? 'open'
2529
- : process.platform === 'win32' ? 'start'
2530
- : 'xdg-open';
2531
- try {
2532
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
2533
- } catch {}
2534
- }
2535
-
2536
- // Derive a stable port from the cwd. Without this, every panel restart picks
2537
- // a different random port and any open browser tab pointing at the OLD port
2538
- // becomes a dead bookmark — confusingly, the bookmark keeps rendering the
2539
- // last-known UI state because /api/status fails silently. Range 9300-9399
2540
- // (above Chrome CDP's 9200-9299).
2541
- function derivePanelPort(projectPath) {
2542
- const crypto = require('crypto');
2543
- const hash = crypto.createHash('sha1').update(projectPath).digest();
2544
- return 9300 + (hash.readUInt16BE(0) % 100);
2545
- }
2546
-
2547
- function start({ cwd = process.cwd(), port = null, openBrowser = true } = {}) {
2548
- const server = http.createServer(handler(cwd));
2549
- let lastActivity = Date.now();
2550
- server.on('request', () => { lastActivity = Date.now(); });
2551
-
2552
- // Port resolution:
2553
- // - explicit port arg (e.g. --port=9999) → use as-is
2554
- // - port === 0 → OS-assigned random (legacy behavior, retained for tests)
2555
- // - default (null) → derive from cwd; on EADDRINUSE fall back to OS-assigned
2556
- const explicitPort = typeof port === 'number' && port > 0;
2557
- const derivedPort = port === null ? derivePanelPort(cwd) : null;
2558
- const initialPort = explicitPort ? port : (derivedPort !== null ? derivedPort : 0);
2559
-
2560
- return new Promise((resolve, reject) => {
2561
- let usingFallback = false;
2562
- server.on('error', (err) => {
2563
- if (err.code === 'EADDRINUSE' && !usingFallback && !explicitPort) {
2564
- // Another process holds the derived port. Fall back to OS-assigned
2565
- // so the panel can still start, but warn the user — they may have
2566
- // another panel running for this project, or unrelated process took
2567
- // the port.
2568
- usingFallback = true;
2569
- console.error(`\n [panel] derived port ${initialPort} is in use — falling back to OS-assigned port.`);
2570
- console.error(` [panel] If another \`claudeflow panel\` is running for this project, kill it first to recover URL stability.\n`);
2571
- server.listen(0, '127.0.0.1');
2572
- return;
2573
- }
2574
- reject(err);
2575
- });
2576
- server.on('listening', () => {
2577
- const addr = server.address();
2578
- const url = `http://127.0.0.1:${addr.port}`;
2579
- const idleTimer = setInterval(() => {
2580
- if (Date.now() - lastActivity > IDLE_SHUTDOWN_MS) {
2581
- clearInterval(idleTimer);
2582
- server.close();
2583
- process.exit(0);
2584
- }
2585
- }, 60 * 1000);
2586
- idleTimer.unref();
2587
- if (openBrowser) openInBrowser(url);
2588
- resolve({ url, server, derivedPort, usedFallback: usingFallback });
2589
- });
2590
- server.listen(initialPort, '127.0.0.1');
2591
- });
2592
- }
2593
-
2594
- async function run(argv = []) {
2595
- const cwd = process.cwd();
2596
- const noOpen = argv.includes('--no-open');
2597
- const portArg = argv.find((a) => a.startsWith('--port='));
2598
- // Default port (null) → derive from cwd so the URL is stable across restarts.
2599
- // --port=N overrides; --port=0 keeps the legacy OS-assigned behavior.
2600
- const port = portArg ? parseInt(portArg.split('=')[1], 10) : null;
2601
-
2602
- ui.banner();
2603
- console.log(` Starting panel for ${ui.CYAN}${cwd}${ui.RESET}`);
2604
- const { url, derivedPort, usedFallback } = await start({ cwd, port, openBrowser: !noOpen });
2605
- console.log('');
2606
- console.log(` ${ui.GREEN}▸${ui.RESET} ${ui.CYAN}${url}${ui.RESET}`);
2607
- if (derivedPort !== null && !usedFallback) {
2608
- console.log(` ${ui.DIM}Port ${derivedPort} is derived from the cwd — same URL across restarts.${ui.RESET}`);
2609
- }
2610
- console.log(` ${ui.DIM}Ctrl+C to stop. Auto-shutdown after 30 min idle.${ui.RESET}`);
2611
- console.log('');
2612
- return new Promise(() => {}); // keep alive
2613
- }
2614
-
2615
- module.exports = run;
2616
- module.exports.start = start;
2617
- module.exports.createPanelServer = handler;
2618
- module.exports.derivePanelPort = derivePanelPort;
2619
- module.exports.getObserverInfo = getObserverInfo;
2620
- module.exports.getValidatorsInfo = getValidatorsInfo;
2621
- module.exports.collectStatus = collectStatus;
2622
- module.exports.getClaudeMdInfo = getClaudeMdInfo;
2623
- module.exports.getHooksInfo = getHooksInfo;
2624
- module.exports.getMcpInfo = getMcpInfo;
2625
- module.exports.getSetupContextInfo = getSetupContextInfo;
2626
- module.exports.getActiveRunInfo = getActiveRunInfo;
2627
- module.exports.getDoctorInfo = getDoctorInfo;
2628
- module.exports.renderHtml = renderHtml;